Initial QSfera import
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
.idea
|
||||
.github
|
||||
lab
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Oleku Konko
|
||||
|
||||
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:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
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.
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
# 🐱 `cat` - The Fast & Fluent String Concatenation Library for Go
|
||||
|
||||
> **"Because building strings shouldn't feel like herding cats"** 😼
|
||||
|
||||
## Why `cat`?
|
||||
|
||||
Go's `strings.Builder` is great, but building complex strings often feels clunky. `cat` makes string concatenation:
|
||||
|
||||
- **Faster** - Optimized paths for common types, zero-allocation conversions
|
||||
- **Fluent** - Chainable methods for beautiful, readable code
|
||||
- **Flexible** - Handles any type, nested structures, and custom formatting
|
||||
- **Smart** - Automatic pooling, size estimation, and separator handling
|
||||
|
||||
```go
|
||||
// Without cat
|
||||
var b strings.Builder
|
||||
b.WriteString("Hello, ")
|
||||
b.WriteString(user.Name)
|
||||
b.WriteString("! You have ")
|
||||
b.WriteString(strconv.Itoa(count))
|
||||
b.WriteString(" new messages.")
|
||||
result := b.String()
|
||||
|
||||
// With cat
|
||||
result := cat.Concat("Hello, ", user.Name, "! You have ", count, " new messages.")
|
||||
```
|
||||
|
||||
## 🔥 Hot Features
|
||||
|
||||
### 1. Fluent Builder API
|
||||
|
||||
Build strings like a boss with method chaining:
|
||||
|
||||
```go
|
||||
s := cat.New(", ").
|
||||
Add("apple").
|
||||
If(user.IsVIP, "golden kiwi").
|
||||
Add("orange").
|
||||
Sep(" | "). // Change separator mid-way
|
||||
Add("banana").
|
||||
String()
|
||||
// "apple, golden kiwi, orange | banana"
|
||||
```
|
||||
|
||||
### 2. Zero-Allocation Magic
|
||||
|
||||
- **Pooled builders** (optional) reduce GC pressure
|
||||
- **Unsafe byte conversions** (opt-in) avoid `[]byte`→`string` copies
|
||||
- **Stack buffers** for numbers instead of heap allocations
|
||||
|
||||
```go
|
||||
// Enable performance features
|
||||
cat.Pool(true) // Builder pooling
|
||||
cat.SetUnsafeBytes(true) // Zero-copy []byte conversion
|
||||
```
|
||||
|
||||
### 3. Handles Any Type - Even Nested Ones!
|
||||
|
||||
No more manual type conversions:
|
||||
|
||||
```go
|
||||
data := map[string]any{
|
||||
"id": 12345,
|
||||
"tags": []string{"go", "fast", "efficient"},
|
||||
}
|
||||
|
||||
fmt.Println(cat.JSONPretty(data))
|
||||
// {
|
||||
// "id": 12345,
|
||||
// "tags": ["go", "fast", "efficient"]
|
||||
// }
|
||||
```
|
||||
|
||||
### 4. Concatenation for Every Use Case
|
||||
|
||||
```go
|
||||
// Simple joins
|
||||
cat.With(", ", "apple", "banana", "cherry") // "apple, banana, cherry"
|
||||
|
||||
// File paths
|
||||
cat.Path("dir", "sub", "file.txt") // "dir/sub/file.txt"
|
||||
|
||||
// CSV
|
||||
cat.CSV(1, 2, 3) // "1,2,3"
|
||||
|
||||
// Conditional elements
|
||||
cat.Start("Hello").If(user != nil, " ", user.Name) // "Hello" or "Hello Alice"
|
||||
|
||||
// Repeated patterns
|
||||
cat.RepeatWith("-+", "X", 3) // "X-+X-+X"
|
||||
```
|
||||
|
||||
### 5. Smarter Than Your Average String Lib
|
||||
|
||||
```go
|
||||
// Automatic nesting handling
|
||||
nested := []any{"a", []any{"b", "c"}, "d"}
|
||||
cat.FlattenWith(",", nested) // "a,b,c,d"
|
||||
|
||||
// Precise size estimation (minimizes allocations)
|
||||
b := cat.New(", ").Grow(estimatedSize) // Preallocate exactly what you need
|
||||
|
||||
// Reflection support for any type
|
||||
cat.Reflect(anyComplexStruct) // "{Field1:value Field2:[1 2 3]}"
|
||||
```
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
```bash
|
||||
go get github.com/your-repo/cat
|
||||
```
|
||||
|
||||
```go
|
||||
import "github.com/your-repo/cat"
|
||||
|
||||
func main() {
|
||||
// Simple concatenation
|
||||
msg := cat.Concat("User ", userID, " has ", count, " items")
|
||||
|
||||
// Pooled builder (for high-performance loops)
|
||||
builder := cat.New(", ")
|
||||
defer builder.Release() // Return to pool
|
||||
result := builder.Add(items...).String()
|
||||
}
|
||||
```
|
||||
|
||||
## 🤔 Why Not Just Use...?
|
||||
|
||||
- `fmt.Sprintf` - Slow, many allocations
|
||||
- `strings.Join` - Only works with strings
|
||||
- `bytes.Buffer` - No separator support, manual type handling
|
||||
- `string +` - Even worse performance, especially in loops
|
||||
|
||||
## 💡 Pro Tips
|
||||
|
||||
1. **Enable pooling** in high-throughput scenarios
|
||||
2. **Preallocate** with `.Grow()` when you know the final size
|
||||
3. Use **`If()`** for conditional elements in fluent chains
|
||||
4. Try **`SetUnsafeBytes(true)`** if you can guarantee byte slices won't mutate
|
||||
5. **Release builders** when pooling is enabled
|
||||
|
||||
## 🐱👤 Advanced Usage
|
||||
|
||||
```go
|
||||
// Custom value formatting
|
||||
type User struct {
|
||||
Name string
|
||||
Age int
|
||||
}
|
||||
|
||||
func (u User) String() string {
|
||||
return cat.With(" ", u.Name, cat.Wrap("(", u.Age, ")"))
|
||||
}
|
||||
|
||||
// JSON-like output
|
||||
func JSONPretty(v any) string {
|
||||
return cat.WrapWith(",\n ", "{\n ", "\n}", prettyFields(v))
|
||||
}
|
||||
```
|
||||
|
||||
```text
|
||||
/\_/\
|
||||
( o.o ) > Concatenate with purr-fection!
|
||||
> ^ <
|
||||
|
||||
```
|
||||
|
||||
**`cat`** - Because life's too short for ugly string building code. 😻
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package cat
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Builder is a fluent concatenation helper. It is safe for concurrent use by
|
||||
// multiple goroutines only if each goroutine uses a distinct *Builder.
|
||||
// If pooling is enabled via Pool(true), call Release() when done.
|
||||
// The Builder uses an internal strings.Builder for efficient string concatenation
|
||||
// and manages a separator that is inserted between added values.
|
||||
// It supports chaining methods for a fluent API style.
|
||||
type Builder struct {
|
||||
buf strings.Builder
|
||||
sep string
|
||||
needsSep bool
|
||||
}
|
||||
|
||||
// New begins a new Builder with a separator. If pooling is enabled,
|
||||
// the Builder is reused and MUST be released with b.Release() when done.
|
||||
// If sep is empty, uses DefaultSep().
|
||||
// Optional initial arguments x are added immediately after creation.
|
||||
// Pooling is controlled globally via Pool(true/false); when enabled, Builders
|
||||
// are recycled to reduce allocations in high-throughput scenarios.
|
||||
func New(sep string, x ...any) *Builder {
|
||||
var b *Builder
|
||||
if poolEnabled.Load() {
|
||||
b = builderPool.Get().(*Builder)
|
||||
b.buf.Reset()
|
||||
b.sep = sep
|
||||
b.needsSep = false
|
||||
} else {
|
||||
b = &Builder{sep: sep}
|
||||
}
|
||||
|
||||
// Process initial arguments *after* the builder is prepared.
|
||||
if len(x) > 0 {
|
||||
b.Add(x...)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Start begins a new Builder with no separator (using an empty string as sep).
|
||||
// It is a convenience function that wraps New(empty, x...), where empty is a constant empty string.
|
||||
// This allows starting a concatenation without any separator between initial or subsequent additions.
|
||||
// If pooling is enabled via Pool(true), the returned Builder MUST be released with b.Release() when done.
|
||||
// Optional variadic arguments x are passed directly to New and added immediately after creation.
|
||||
// Useful for fluent chains where no default separator is desired from the start.
|
||||
func Start(x ...any) *Builder {
|
||||
return New(empty, x...)
|
||||
}
|
||||
|
||||
// Grow pre-sizes the internal buffer.
|
||||
// This can be used to preallocate capacity based on an estimated total size,
|
||||
// reducing reallocations during subsequent Add calls.
|
||||
// It chains, returning the Builder for fluent use.
|
||||
func (b *Builder) Grow(n int) *Builder { b.buf.Grow(n); return b }
|
||||
|
||||
// Add appends values to the builder.
|
||||
// It inserts the current separator before each new value if needed (i.e., after the first addition).
|
||||
// Values are converted to strings using the optimized write function, which handles
|
||||
// common types efficiently without allocations where possible.
|
||||
// Supports any number of arguments of any type.
|
||||
// Chains, returning the Builder for fluent use.
|
||||
func (b *Builder) Add(args ...any) *Builder {
|
||||
for _, arg := range args {
|
||||
if b.needsSep && b.sep != empty {
|
||||
b.buf.WriteString(b.sep)
|
||||
}
|
||||
write(&b.buf, arg)
|
||||
b.needsSep = true
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// If appends values to the builder only if the condition is true.
|
||||
// Behaves like Add when condition is true; does nothing otherwise.
|
||||
// Useful for conditional concatenation in chains.
|
||||
// Chains, returning the Builder for fluent use.
|
||||
func (b *Builder) If(condition bool, args ...any) *Builder {
|
||||
if condition {
|
||||
b.Add(args...)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Sep changes the separator for subsequent additions.
|
||||
// Future Add calls will use this new separator.
|
||||
// Does not affect already added content.
|
||||
// If sep is empty, no separator will be added between future values.
|
||||
// Chains, returning the Builder for fluent use.
|
||||
func (b *Builder) Sep(sep string) *Builder { b.sep = sep; return b }
|
||||
|
||||
// String returns the concatenated result.
|
||||
// This does not release the Builder; if pooling is enabled, call Release separately
|
||||
// if you are done with the Builder.
|
||||
// Can be called multiple times; the internal buffer remains unchanged.
|
||||
func (b *Builder) String() string { return b.buf.String() }
|
||||
|
||||
// Output returns the concatenated result and releases the Builder if pooling is enabled.
|
||||
// This is a convenience method to get the string and clean up in one call.
|
||||
// After Output, the Builder should not be used further if pooled, as it may be recycled.
|
||||
// If pooling is disabled, it behaves like String without release.
|
||||
func (b *Builder) Output() string {
|
||||
out := b.buf.String()
|
||||
b.Release() // Release takes care of the poolEnabled check
|
||||
return out
|
||||
}
|
||||
|
||||
// Release returns the Builder to the pool if pooling is enabled.
|
||||
// You should call this exactly once per New() when Pool(true) is active.
|
||||
// Resets the internal state (buffer, separator, needsSep) before pooling to avoid
|
||||
// retaining data or large allocations.
|
||||
// If pooling is disabled, this is a no-op.
|
||||
// Safe to call multiple times, but typically called once at the end of use.
|
||||
func (b *Builder) Release() {
|
||||
if poolEnabled.Load() {
|
||||
// Avoid retaining large buffers.
|
||||
b.buf.Reset()
|
||||
b.sep = empty
|
||||
b.needsSep = false
|
||||
builderPool.Put(b)
|
||||
}
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
// Package cat provides efficient and flexible string concatenation utilities.
|
||||
// It includes optimized functions for concatenating various types, builders for fluent chaining,
|
||||
// and configuration options for defaults, pooling, and unsafe optimizations.
|
||||
// The package aims to minimize allocations and improve performance in string building scenarios.
|
||||
package cat
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Constants used throughout the package for separators, defaults, and configuration.
|
||||
// These include common string literals for separators, empty strings, and special representations,
|
||||
// as well as limits like recursion depth. Defining them as constants allows for compile-time
|
||||
// optimizations, readability, and consistent usage in functions like Space, Path, CSV, and reflection handlers.
|
||||
// cat.go (updated constants section)
|
||||
const (
|
||||
empty = "" // Empty string constant, used for checks and defaults.
|
||||
space = " " // Single space, default separator.
|
||||
slash = "/" // Forward slash, for paths.
|
||||
dot = "." // Period, for extensions or decimals.
|
||||
comma = "," // Comma, for CSV or lists.
|
||||
equal = "=" // Equals, for comparisons.
|
||||
newline = "\n" // Newline, for multi-line strings.
|
||||
|
||||
// SQL-specific constants
|
||||
and = "AND" // AND operator, for SQL conditions.
|
||||
inOpen = " IN (" // Opening for SQL IN clause
|
||||
inClose = ")" // Closing for SQL IN clause
|
||||
asSQL = " AS " // SQL AS for aliasing
|
||||
count = "COUNT(" // SQL COUNT function prefix
|
||||
sum = "SUM(" // SQL SUM function prefix
|
||||
avg = "AVG(" // SQL AVG function prefix
|
||||
maxOpen = "MAX(" // SQL MAX function prefix
|
||||
minOpen = "MIN(" // SQL MIN function prefix
|
||||
caseSQL = "CASE " // SQL CASE keyword
|
||||
when = "WHEN " // SQL WHEN clause
|
||||
then = " THEN " // SQL THEN clause
|
||||
elseSQL = " ELSE " // SQL ELSE clause
|
||||
end = " END" // SQL END for CASE
|
||||
countAll = "COUNT(*)" // SQL COUNT(*) for all rows
|
||||
parenOpen = "(" // Opening parenthesis
|
||||
parenClose = ")" // Closing parenthesis
|
||||
|
||||
maxRecursionDepth = 32 // Maximum recursion depth for nested structure handling.
|
||||
nilString = "<nil>" // String representation for nil values.
|
||||
unexportedString = "<?>" // Placeholder for unexported fields.
|
||||
)
|
||||
|
||||
// Numeric is a generic constraint interface for numeric types.
|
||||
// It includes all signed/unsigned integers and floats.
|
||||
// Used in generic functions like Number and NumberWith to constrain to numbers.
|
||||
type Numeric interface {
|
||||
~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~float32 | ~float64
|
||||
}
|
||||
|
||||
// poolEnabled controls whether New() reuses Builder instances from a pool.
|
||||
// Atomic.Bool for thread-safe toggle.
|
||||
// When true, Builders from New must be Released to avoid leaks.
|
||||
var poolEnabled atomic.Bool
|
||||
|
||||
// builderPool stores reusable *Builder to reduce GC pressure on hot paths.
|
||||
// Uses sync.Pool for efficient allocation/reuse.
|
||||
// New func creates a fresh &Builder when pool is empty.
|
||||
var builderPool = sync.Pool{
|
||||
New: func() any { return &Builder{} },
|
||||
}
|
||||
|
||||
// Pool enables or disables Builder pooling for New()/Release().
|
||||
// When enabled, you MUST call b.Release() after b.String() to return it.
|
||||
// Thread-safe via atomic.Store.
|
||||
// Enable for high-throughput scenarios to reduce allocations.
|
||||
func Pool(enable bool) { poolEnabled.Store(enable) }
|
||||
|
||||
// unsafeBytesFlag controls zero-copy []byte -> string behavior via atomics.
|
||||
// Int32 used for atomic operations: 1 = enabled, 0 = disabled.
|
||||
// Affects bytesToString function for zero-copy conversions using unsafe.
|
||||
var unsafeBytesFlag atomic.Int32 // 1 = true, 0 = false
|
||||
|
||||
// SetUnsafeBytes toggles zero-copy []byte -> string conversions globally.
|
||||
// When enabled, bytesToString uses unsafe.String for zero-allocation conversion.
|
||||
// Thread-safe via atomic.Store.
|
||||
// Use with caution: assumes the byte slice is not modified after conversion.
|
||||
// Compatible with Go 1.20+; fallback to string(bts) if disabled.
|
||||
func SetUnsafeBytes(enable bool) {
|
||||
if enable {
|
||||
unsafeBytesFlag.Store(1)
|
||||
} else {
|
||||
unsafeBytesFlag.Store(0)
|
||||
}
|
||||
}
|
||||
|
||||
// IsUnsafeBytes reports whether zero-copy []byte -> string is enabled.
|
||||
// Thread-safe via atomic.Load.
|
||||
// Returns true if flag is 1, false otherwise.
|
||||
// Useful for checking current configuration.
|
||||
func IsUnsafeBytes() bool { return unsafeBytesFlag.Load() == 1 }
|
||||
|
||||
// deterministicMaps controls whether map keys are sorted for deterministic output in string conversions.
|
||||
// It uses atomic.Bool for thread-safe access.
|
||||
var deterministicMaps atomic.Bool
|
||||
|
||||
// SetDeterministicMaps controls whether map keys are sorted for deterministic output
|
||||
// in reflection-based handling (e.g., in writeReflect for maps).
|
||||
// When enabled, keys are sorted using a string-based comparison for consistent string representations.
|
||||
// Thread-safe via atomic.Store.
|
||||
// Useful for reproducible outputs in testing or logging.
|
||||
func SetDeterministicMaps(enable bool) {
|
||||
deterministicMaps.Store(enable)
|
||||
}
|
||||
|
||||
// IsDeterministicMaps returns current map sorting setting.
|
||||
// Thread-safe via atomic.Load.
|
||||
// Returns true if deterministic sorting is enabled, false otherwise.
|
||||
func IsDeterministicMaps() bool {
|
||||
return deterministicMaps.Load()
|
||||
}
|
||||
+590
@@ -0,0 +1,590 @@
|
||||
package cat
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Append appends args to dst and returns the grown slice.
|
||||
// Callers can reuse dst across calls to amortize allocs.
|
||||
// It uses an internal Builder for efficient concatenation of the args (no separators),
|
||||
// then appends the result to the dst byte slice.
|
||||
// Preallocates based on a size estimate to minimize reallocations.
|
||||
// Benefits from Builder pooling if enabled.
|
||||
// Useful for building byte slices incrementally without separators.
|
||||
func Append(dst []byte, args ...any) []byte {
|
||||
return AppendWith(empty, dst, args...)
|
||||
}
|
||||
|
||||
// AppendWith appends args to dst and returns the grown slice.
|
||||
// Callers can reuse dst across calls to amortize allocs.
|
||||
// Similar to Append, but inserts the specified sep between each arg.
|
||||
// Preallocates based on a size estimate including separators.
|
||||
// Benefits from Builder pooling if enabled.
|
||||
// Useful for building byte slices incrementally with custom separators.
|
||||
func AppendWith(sep string, dst []byte, args ...any) []byte {
|
||||
if len(args) == 0 {
|
||||
return dst
|
||||
}
|
||||
b := New(sep)
|
||||
b.Grow(estimateWith(sep, args))
|
||||
b.Add(args...)
|
||||
out := b.Output()
|
||||
return append(dst, out...)
|
||||
}
|
||||
|
||||
// AppendBytes joins byte slices without separators.
|
||||
// Only for compatibility with low-level byte processing.
|
||||
// Directly appends each []byte arg to dst without any conversion or separators.
|
||||
// Efficient for pure byte concatenation; no allocations if dst has capacity.
|
||||
// Returns the extended dst slice.
|
||||
// Does not use Builder, as it's simple append operations.
|
||||
func AppendBytes(dst []byte, args ...[]byte) []byte {
|
||||
if len(args) == 0 {
|
||||
return dst
|
||||
}
|
||||
for _, b := range args {
|
||||
dst = append(dst, b...)
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// AppendTo writes arguments to an existing strings.Builder.
|
||||
// More efficient than creating new builders.
|
||||
// Appends each arg to the provided strings.Builder using the optimized write function.
|
||||
// No separators are added; for direct concatenation.
|
||||
// Useful when you already have a strings.Builder and want to add more values efficiently.
|
||||
// Does not use cat.Builder, as it appends to an existing strings.Builder.
|
||||
func AppendTo(b *strings.Builder, args ...any) {
|
||||
for _, arg := range args {
|
||||
write(b, arg)
|
||||
}
|
||||
}
|
||||
|
||||
// AppendStrings writes strings to an existing strings.Builder.
|
||||
// Directly writes each string arg to the provided strings.Builder.
|
||||
// No type checks or conversions; assumes all args are strings.
|
||||
// Efficient for appending known strings without separators.
|
||||
// Does not use cat.Builder, as it appends to an existing strings.Builder.
|
||||
func AppendStrings(b *strings.Builder, ss ...string) {
|
||||
for _, s := range ss {
|
||||
b.WriteString(s)
|
||||
}
|
||||
}
|
||||
|
||||
// Between concatenates values wrapped between x and y (no separator between args).
|
||||
// Equivalent to BetweenWith with an empty separator.
|
||||
func Between(x, y any, args ...any) string {
|
||||
return BetweenWith(empty, x, y, args...)
|
||||
}
|
||||
|
||||
// BetweenWith concatenates values wrapped between x and y, using sep between x, args, and y.
|
||||
// Uses a pooled Builder if enabled; releases it after use.
|
||||
// Equivalent to With(sep, x, args..., y).
|
||||
func BetweenWith(sep string, x, y any, args ...any) string {
|
||||
b := New(sep)
|
||||
// Estimate size for all parts to avoid re-allocation.
|
||||
b.Grow(estimate([]any{x, y}) + estimateWith(sep, args))
|
||||
|
||||
b.Add(x)
|
||||
b.Add(args...)
|
||||
b.Add(y)
|
||||
|
||||
return b.Output()
|
||||
}
|
||||
|
||||
// CSV joins arguments with "," separators (no space).
|
||||
// Convenience wrapper for With using a comma as separator.
|
||||
// Useful for simple CSV string generation without spaces.
|
||||
func CSV(args ...any) string { return With(comma, args...) }
|
||||
|
||||
// Comma joins arguments with ", " separators.
|
||||
// Convenience wrapper for With using ", " as separator.
|
||||
// Useful for human-readable lists with comma and space.
|
||||
func Comma(args ...any) string { return With(comma+space, args...) }
|
||||
|
||||
// Concat concatenates any values (no separators).
|
||||
// Usage: cat.Concat("a", 1, true) → "a1true"
|
||||
// Equivalent to With with an empty separator.
|
||||
func Concat(args ...any) string {
|
||||
return With(empty, args...)
|
||||
}
|
||||
|
||||
// ConcatWith concatenates any values with separator.
|
||||
// Alias for With; joins args with the provided sep.
|
||||
func ConcatWith(sep string, args ...any) string {
|
||||
return With(sep, args...)
|
||||
}
|
||||
|
||||
// Flatten joins nested values into a single concatenation using empty.
|
||||
// Convenience for FlattenWith using empty.
|
||||
func Flatten(args ...any) string {
|
||||
return FlattenWith(empty, args...)
|
||||
}
|
||||
|
||||
// FlattenWith joins nested values into a single concatenation with sep, avoiding
|
||||
// intermediate slice allocations where possible.
|
||||
// It recursively flattens any nested []any arguments, concatenating all leaf items
|
||||
// with sep between them. Skips empty nested slices to avoid extra separators.
|
||||
// Leaf items (non-slices) are converted using the optimized write function.
|
||||
// Uses a pooled Builder if enabled; releases it after use.
|
||||
// Preallocates based on a recursive estimate for efficiency.
|
||||
// Example: FlattenWith(",", 1, []any{2, []any{3,4}}, 5) → "1,2,3,4,5"
|
||||
func FlattenWith(sep string, args ...any) string {
|
||||
if len(args) == 0 {
|
||||
return empty
|
||||
}
|
||||
|
||||
// Recursive estimate for preallocation.
|
||||
totalSize := recursiveEstimate(sep, args)
|
||||
|
||||
b := New(sep)
|
||||
b.Grow(totalSize)
|
||||
recursiveAdd(b, args)
|
||||
return b.Output()
|
||||
}
|
||||
|
||||
// Group joins multiple groups with empty between groups (no intra-group separators).
|
||||
// Convenience for GroupWith using empty.
|
||||
func Group(groups ...[]any) string {
|
||||
return GroupWith(empty, groups...)
|
||||
}
|
||||
|
||||
// GroupWith joins multiple groups with a separator between groups (no intra-group separators).
|
||||
// Concatenates each group internally without separators, then joins non-empty groups with sep.
|
||||
// Preestimates total size for allocation; uses pooled Builder if enabled.
|
||||
// Optimized for single group: direct Concat.
|
||||
// Useful for grouping related items with inter-group separation.
|
||||
func GroupWith(sep string, groups ...[]any) string {
|
||||
if len(groups) == 0 {
|
||||
return empty
|
||||
}
|
||||
if len(groups) == 1 {
|
||||
return Concat(groups[0]...)
|
||||
}
|
||||
|
||||
total := 0
|
||||
nonEmpty := 0
|
||||
for _, g := range groups {
|
||||
if len(g) == 0 {
|
||||
continue
|
||||
}
|
||||
if nonEmpty > 0 {
|
||||
total += len(sep)
|
||||
}
|
||||
total += estimate(g)
|
||||
nonEmpty++
|
||||
}
|
||||
|
||||
b := New(empty)
|
||||
b.Grow(total)
|
||||
first := true
|
||||
for _, g := range groups {
|
||||
if len(g) == 0 {
|
||||
continue
|
||||
}
|
||||
if !first && sep != empty {
|
||||
b.buf.WriteString(sep)
|
||||
}
|
||||
first = false
|
||||
for _, a := range g {
|
||||
write(&b.buf, a)
|
||||
}
|
||||
}
|
||||
return b.Output()
|
||||
}
|
||||
|
||||
// Indent prefixes the concatenation of args with depth levels of two spaces per level.
|
||||
// Example: Indent(2, "hello") => " hello"
|
||||
// If depth <= 0, equivalent to Concat(args...).
|
||||
// Uses " " repeated depth times as prefix, followed by concatenated args (no separators).
|
||||
// Benefits from pooling via Concat.
|
||||
func Indent(depth int, args ...any) string {
|
||||
if depth <= 0 {
|
||||
return Concat(args...)
|
||||
}
|
||||
prefix := strings.Repeat(" ", depth)
|
||||
return Prefix(prefix, args...)
|
||||
}
|
||||
|
||||
// Join joins strings (matches stdlib strings.Join behavior).
|
||||
// Usage: cat.Join("a", "b") → "a b" (using empty)
|
||||
// Joins the variadic string args with the current empty.
|
||||
// Useful for compatibility with stdlib but using package default sep.
|
||||
func Join(elems ...string) string {
|
||||
return strings.Join(elems, empty)
|
||||
}
|
||||
|
||||
// JoinWith joins strings with separator (variadic version).
|
||||
// Directly uses strings.Join on the variadic string args with sep.
|
||||
// Efficient for known strings; no conversions needed.
|
||||
func JoinWith(sep string, elems ...string) string {
|
||||
return strings.Join(elems, sep)
|
||||
}
|
||||
|
||||
// Lines joins arguments with newline separators.
|
||||
// Convenience for With using "\n" as separator.
|
||||
// Useful for building multi-line strings.
|
||||
func Lines(args ...any) string { return With(newline, args...) }
|
||||
|
||||
// Number concatenates numeric values without separators.
|
||||
// Generic over Numeric types.
|
||||
// Equivalent to NumberWith with empty sep.
|
||||
func Number[T Numeric](a ...T) string {
|
||||
return NumberWith(empty, a...)
|
||||
}
|
||||
|
||||
// NumberWith concatenates numeric values with the provided separator.
|
||||
// Generic over Numeric types.
|
||||
// If no args, returns empty string.
|
||||
// Uses pooled Builder if enabled, with rough growth estimate (8 bytes per item).
|
||||
// Relies on valueToString for numeric conversion.
|
||||
func NumberWith[T Numeric](sep string, a ...T) string {
|
||||
if len(a) == 0 {
|
||||
return empty
|
||||
}
|
||||
|
||||
b := New(sep)
|
||||
b.Grow(len(a) * 8)
|
||||
for _, v := range a {
|
||||
b.Add(v)
|
||||
}
|
||||
return b.Output()
|
||||
}
|
||||
|
||||
// Path joins arguments with "/" separators.
|
||||
// Convenience for With using "/" as separator.
|
||||
// Useful for building file paths or URLs.
|
||||
func Path(args ...any) string { return With(slash, args...) }
|
||||
|
||||
// Prefix concatenates with a prefix (no separator).
|
||||
// Equivalent to PrefixWith with empty sep.
|
||||
func Prefix(p any, args ...any) string {
|
||||
return PrefixWith(empty, p, args...)
|
||||
}
|
||||
|
||||
// PrefixWith concatenates with a prefix and separator.
|
||||
// Adds p, then sep (if args present and sep not empty), then joins args with sep.
|
||||
// Uses pooled Builder if enabled.
|
||||
func PrefixWith(sep string, p any, args ...any) string {
|
||||
b := New(sep)
|
||||
b.Grow(estimateWith(sep, args) + estimate([]any{p}))
|
||||
b.Add(p)
|
||||
b.Add(args...)
|
||||
return b.Output()
|
||||
}
|
||||
|
||||
// PrefixEach applies the same prefix to each argument and joins the pairs with sep.
|
||||
// Example: PrefixEach("pre-", ",", "a","b") => "pre-a,pre-b"
|
||||
// Preestimates size including prefixes and seps.
|
||||
// Uses pooled Builder if enabled; manually adds sep between pairs, no sep between p and a.
|
||||
// Returns empty if no args.
|
||||
func PrefixEach(p any, sep string, args ...any) string {
|
||||
if len(args) == 0 {
|
||||
return empty
|
||||
}
|
||||
pSize := estimate([]any{p})
|
||||
total := len(sep)*(len(args)-1) + estimate(args) + pSize*len(args)
|
||||
|
||||
b := New(empty)
|
||||
b.Grow(total)
|
||||
for i, a := range args {
|
||||
if i > 0 && sep != empty {
|
||||
b.buf.WriteString(sep)
|
||||
}
|
||||
write(&b.buf, p)
|
||||
write(&b.buf, a)
|
||||
}
|
||||
return b.Output()
|
||||
}
|
||||
|
||||
// Pair joins exactly two values (no separator).
|
||||
// Equivalent to PairWith with empty sep.
|
||||
func Pair(a, b any) string {
|
||||
return PairWith(empty, a, b)
|
||||
}
|
||||
|
||||
// PairWith joins exactly two values with a separator.
|
||||
// Optimized for two args: uses With(sep, a, b).
|
||||
func PairWith(sep string, a, b any) string {
|
||||
return With(sep, a, b)
|
||||
}
|
||||
|
||||
// Quote wraps each argument in double quotes, separated by spaces.
|
||||
// Equivalent to QuoteWith with '"' as quote.
|
||||
func Quote(args ...any) string {
|
||||
return QuoteWith('"', args...)
|
||||
}
|
||||
|
||||
// QuoteWith wraps each argument with the specified quote byte, separated by spaces.
|
||||
// Wraps each arg with quote, writes arg, closes with quote; joins with space.
|
||||
// Preestimates with quotes and spaces.
|
||||
// Uses pooled Builder if enabled.
|
||||
func QuoteWith(quote byte, args ...any) string {
|
||||
if len(args) == 0 {
|
||||
return empty
|
||||
}
|
||||
total := estimate(args) + 2*len(args) + len(space)*(len(args)-1)
|
||||
|
||||
b := New(empty)
|
||||
b.Grow(total)
|
||||
need := false
|
||||
for _, a := range args {
|
||||
if need {
|
||||
b.buf.WriteString(space)
|
||||
}
|
||||
b.buf.WriteByte(quote)
|
||||
write(&b.buf, a)
|
||||
b.buf.WriteByte(quote)
|
||||
need = true
|
||||
}
|
||||
return b.Output()
|
||||
}
|
||||
|
||||
// Repeat concatenates val n times (no sep between instances).
|
||||
// Equivalent to RepeatWith with empty sep.
|
||||
func Repeat(val any, n int) string {
|
||||
return RepeatWith(empty, val, n)
|
||||
}
|
||||
|
||||
// RepeatWith concatenates val n times with sep between each instance.
|
||||
// If n <= 0, returns an empty string.
|
||||
// Optimized to make exactly one allocation; converts val once.
|
||||
// Uses pooled Builder if enabled.
|
||||
func RepeatWith(sep string, val any, n int) string {
|
||||
if n <= 0 {
|
||||
return empty
|
||||
}
|
||||
if n == 1 {
|
||||
return valueToString(val)
|
||||
}
|
||||
b := New(sep)
|
||||
b.Grow(n*estimate([]any{val}) + (n-1)*len(sep))
|
||||
for i := 0; i < n; i++ {
|
||||
b.Add(val)
|
||||
}
|
||||
return b.Output()
|
||||
}
|
||||
|
||||
// Reflect converts a reflect.Value to its string representation.
|
||||
// It handles all kinds of reflected values including primitives, structs, slices, maps, etc.
|
||||
// For nil values, it returns the nilString constant ("<nil>").
|
||||
// For unexported or inaccessible fields, it returns unexportedString ("<?>").
|
||||
// The output follows Go's syntax conventions where applicable (e.g., slices as [a, b], maps as {k:v}).
|
||||
func Reflect(r reflect.Value) string {
|
||||
if !r.IsValid() {
|
||||
return nilString
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
writeReflect(&b, r.Interface(), 0)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Space concatenates arguments with space separators.
|
||||
// Convenience for With using " " as separator.
|
||||
func Space(args ...any) string { return With(space, args...) }
|
||||
|
||||
// Dot concatenates arguments with dot separators.
|
||||
// Convenience for With using " " as separator.
|
||||
func Dot(args ...any) string { return With(dot, args...) }
|
||||
|
||||
// Suffix concatenates with a suffix (no separator).
|
||||
// Equivalent to SuffixWith with empty sep.
|
||||
func Suffix(s any, args ...any) string {
|
||||
return SuffixWith(empty, s, args...)
|
||||
}
|
||||
|
||||
// SuffixWith concatenates with a suffix and separator.
|
||||
// Joins args with sep, then adds sep (if args present and sep not empty), then s.
|
||||
// Uses pooled Builder if enabled.
|
||||
func SuffixWith(sep string, s any, args ...any) string {
|
||||
b := New(sep)
|
||||
b.Grow(estimateWith(sep, args) + estimate([]any{s}))
|
||||
b.Add(args...)
|
||||
b.Add(s)
|
||||
return b.Output()
|
||||
}
|
||||
|
||||
// SuffixEach applies the same suffix to each argument and joins the pairs with sep.
|
||||
// Example: SuffixEach("-suf", " | ", "a","b") => "a-suf | b-suf"
|
||||
// Preestimates size including suffixes and seps.
|
||||
// Uses pooled Builder if enabled; manually adds sep between pairs, no sep between a and s.
|
||||
// Returns empty if no args.
|
||||
func SuffixEach(s any, sep string, args ...any) string {
|
||||
if len(args) == 0 {
|
||||
return empty
|
||||
}
|
||||
sSize := estimate([]any{s})
|
||||
total := len(sep)*(len(args)-1) + estimate(args) + sSize*len(args)
|
||||
|
||||
b := New(empty)
|
||||
b.Grow(total)
|
||||
for i, a := range args {
|
||||
if i > 0 && sep != empty {
|
||||
b.buf.WriteString(sep)
|
||||
}
|
||||
write(&b.buf, a)
|
||||
write(&b.buf, s)
|
||||
}
|
||||
return b.Output()
|
||||
}
|
||||
|
||||
// Sprint concatenates any values (no separators).
|
||||
// Usage: Sprint("a", 1, true) → "a1true"
|
||||
// Equivalent to Concat or With with an empty separator.
|
||||
func Sprint(args ...any) string {
|
||||
if len(args) == 0 {
|
||||
return empty
|
||||
}
|
||||
if len(args) == 1 {
|
||||
return valueToString(args[0])
|
||||
}
|
||||
|
||||
// For multiple args, use the existing Concat functionality
|
||||
return Concat(args...)
|
||||
}
|
||||
|
||||
// Trio joins exactly three values (no separator).
|
||||
// Equivalent to TrioWith with empty sep
|
||||
func Trio(a, b, c any) string {
|
||||
return TrioWith(empty, a, b, c)
|
||||
}
|
||||
|
||||
// TrioWith joins exactly three values with a separator.
|
||||
// Optimized for three args: uses With(sep, a, b, c).
|
||||
func TrioWith(sep string, a, b, c any) string {
|
||||
return With(sep, a, b, c)
|
||||
}
|
||||
|
||||
// With concatenates arguments with the specified separator.
|
||||
// Core concatenation function with sep.
|
||||
// Optimized for zero or one arg: empty or direct valueToString.
|
||||
// Fast path for all strings: exact preallocation, direct writes via raw strings.Builder (minimal branches/allocs).
|
||||
// Fallback: pooled Builder with estimateWith, adds args with sep.
|
||||
// Benefits from pooling if enabled for mixed types.
|
||||
func With(sep string, args ...any) string {
|
||||
switch len(args) {
|
||||
case 0:
|
||||
return empty
|
||||
case 1:
|
||||
return valueToString(args[0])
|
||||
}
|
||||
|
||||
// Fast path for all strings: use raw strings.Builder for speed, no pooling needed.
|
||||
allStrings := true
|
||||
totalLen := len(sep) * (len(args) - 1)
|
||||
for _, a := range args {
|
||||
if s, ok := a.(string); ok {
|
||||
totalLen += len(s)
|
||||
} else {
|
||||
allStrings = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if allStrings {
|
||||
var b strings.Builder
|
||||
b.Grow(totalLen)
|
||||
b.WriteString(args[0].(string))
|
||||
for i := 1; i < len(args); i++ {
|
||||
if sep != empty {
|
||||
b.WriteString(sep)
|
||||
}
|
||||
b.WriteString(args[i].(string))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Fallback for mixed types: use pooled Builder.
|
||||
b := New(sep)
|
||||
b.Grow(estimateWith(sep, args))
|
||||
b.Add(args...)
|
||||
return b.Output()
|
||||
}
|
||||
|
||||
// Wrap encloses concatenated args between before and after strings (no inner separator).
|
||||
// Equivalent to Concat(before, args..., after).
|
||||
func Wrap(before, after string, args ...any) string {
|
||||
b := Start()
|
||||
b.Grow(len(before) + len(after) + estimate(args))
|
||||
|
||||
b.Add(before)
|
||||
b.Add(args...)
|
||||
b.Add(after)
|
||||
|
||||
return b.Output()
|
||||
}
|
||||
|
||||
// WrapEach wraps each argument individually with before/after, concatenated without separators.
|
||||
// Applies before + arg + after to each arg.
|
||||
// Preestimates size; uses pooled Builder if enabled.
|
||||
// Returns empty if no args.
|
||||
// Useful for wrapping multiple items identically without joins.
|
||||
func WrapEach(before, after string, args ...any) string {
|
||||
if len(args) == 0 {
|
||||
return empty
|
||||
}
|
||||
total := (len(before)+len(after))*len(args) + estimate(args)
|
||||
|
||||
b := Start() // Use pooled builder, but we will write manually.
|
||||
b.Grow(total)
|
||||
for _, a := range args {
|
||||
write(&b.buf, before)
|
||||
write(&b.buf, a)
|
||||
write(&b.buf, after)
|
||||
}
|
||||
// No separators were ever added, so this is safe.
|
||||
b.needsSep = true // Correctly set state in case of reuse.
|
||||
return b.Output()
|
||||
}
|
||||
|
||||
// WrapWith encloses concatenated args between before and after strings,
|
||||
// joining the arguments with the provided separator.
|
||||
// If no args, returns before + after.
|
||||
// Builds inner with With(sep, args...), then Concat(before, inner, after).
|
||||
// Benefits from pooling via With and Concat.
|
||||
func WrapWith(sep, before, after string, args ...any) string {
|
||||
if len(args) == 0 {
|
||||
return before + after
|
||||
}
|
||||
// First, efficiently build the inner part.
|
||||
inner := With(sep, args...)
|
||||
|
||||
// Then, wrap it without allocating another slice.
|
||||
b := Start()
|
||||
b.Grow(len(before) + len(inner) + len(after))
|
||||
|
||||
b.Add(before)
|
||||
b.Add(inner)
|
||||
b.Add(after)
|
||||
|
||||
return b.Output()
|
||||
}
|
||||
|
||||
// Pad surrounds a string with spaces on both sides.
|
||||
// Ensures proper spacing for SQL operators like "=", "AND", etc.
|
||||
// Example: Pad("=") returns " = " for cleaner formatting.
|
||||
func Pad(s string) string {
|
||||
return Concat(space, s, space)
|
||||
}
|
||||
|
||||
// PadWith adds a separator before the string and a space after it.
|
||||
// Useful for formatting SQL parts with custom leading separators.
|
||||
// Example: PadWith(",", "column") returns ",column ".
|
||||
func PadWith(sep, s string) string {
|
||||
return Concat(sep, s, space)
|
||||
}
|
||||
|
||||
// Parens wraps content in parentheses
|
||||
// Useful for grouping SQL conditions or expressions
|
||||
// Example: Parens("a = b AND c = d") → "(a = b AND c = d)"
|
||||
func Parens(content string) string {
|
||||
return Concat(parenOpen, content, parenClose)
|
||||
}
|
||||
|
||||
// ParensWith wraps multiple arguments in parentheses with a separator
|
||||
// Example: ParensWith(" AND ", "a = b", "c = d") → "(a = b AND c = d)"
|
||||
func ParensWith(sep string, args ...any) string {
|
||||
return Concat(parenOpen, With(sep, args...), parenClose)
|
||||
}
|
||||
+376
@@ -0,0 +1,376 @@
|
||||
package cat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// write writes a value to the given strings.Builder using fast paths to avoid temporary allocations.
|
||||
// It handles common types like strings, byte slices, integers, floats, and booleans directly for efficiency.
|
||||
// For other types, it falls back to fmt.Fprint, which may involve allocations.
|
||||
// This function is optimized for performance in string concatenation scenarios, prioritizing
|
||||
// common cases like strings and numbers at the top of the type switch for compiler optimization.
|
||||
// Note: For integers and floats, it uses stack-allocated buffers and strconv.Append* functions to
|
||||
// convert numbers to strings without heap allocations.
|
||||
func write(b *strings.Builder, arg any) {
|
||||
writeValue(b, arg, 0)
|
||||
}
|
||||
|
||||
// writeValue appends the string representation of arg to b, handling recursion with a depth limit.
|
||||
// It serves as a recursive helper for write, directly handling primitives and delegating complex
|
||||
// types to writeReflect. The depth parameter prevents excessive recursion in deeply nested structures.
|
||||
func writeValue(b *strings.Builder, arg any, depth int) {
|
||||
// Handle recursion depth limit
|
||||
if depth > maxRecursionDepth {
|
||||
b.WriteString("...")
|
||||
return
|
||||
}
|
||||
|
||||
// Handle nil values
|
||||
if arg == nil {
|
||||
b.WriteString(nilString)
|
||||
return
|
||||
}
|
||||
|
||||
// Fast path type switch for all primitive types
|
||||
switch v := arg.(type) {
|
||||
case string:
|
||||
b.WriteString(v)
|
||||
case []byte:
|
||||
b.WriteString(bytesToString(v))
|
||||
case int:
|
||||
var buf [20]byte
|
||||
b.Write(strconv.AppendInt(buf[:0], int64(v), 10))
|
||||
case int64:
|
||||
var buf [20]byte
|
||||
b.Write(strconv.AppendInt(buf[:0], v, 10))
|
||||
case int32:
|
||||
var buf [11]byte
|
||||
b.Write(strconv.AppendInt(buf[:0], int64(v), 10))
|
||||
case int16:
|
||||
var buf [6]byte
|
||||
b.Write(strconv.AppendInt(buf[:0], int64(v), 10))
|
||||
case int8:
|
||||
var buf [4]byte
|
||||
b.Write(strconv.AppendInt(buf[:0], int64(v), 10))
|
||||
case uint:
|
||||
var buf [20]byte
|
||||
b.Write(strconv.AppendUint(buf[:0], uint64(v), 10))
|
||||
case uint64:
|
||||
var buf [20]byte
|
||||
b.Write(strconv.AppendUint(buf[:0], v, 10))
|
||||
case uint32:
|
||||
var buf [10]byte
|
||||
b.Write(strconv.AppendUint(buf[:0], uint64(v), 10))
|
||||
case uint16:
|
||||
var buf [5]byte
|
||||
b.Write(strconv.AppendUint(buf[:0], uint64(v), 10))
|
||||
case uint8:
|
||||
var buf [3]byte
|
||||
b.Write(strconv.AppendUint(buf[:0], uint64(v), 10))
|
||||
case float64:
|
||||
var buf [24]byte
|
||||
b.Write(strconv.AppendFloat(buf[:0], v, 'f', -1, 64))
|
||||
case float32:
|
||||
var buf [24]byte
|
||||
b.Write(strconv.AppendFloat(buf[:0], float64(v), 'f', -1, 32))
|
||||
case bool:
|
||||
if v {
|
||||
b.WriteString("true")
|
||||
} else {
|
||||
b.WriteString("false")
|
||||
}
|
||||
case fmt.Stringer:
|
||||
b.WriteString(v.String())
|
||||
case error:
|
||||
b.WriteString(v.Error())
|
||||
default:
|
||||
// Fallback to reflection-based handling
|
||||
writeReflect(b, arg, depth)
|
||||
}
|
||||
}
|
||||
|
||||
// writeReflect handles all complex types safely.
|
||||
func writeReflect(b *strings.Builder, arg any, depth int) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
b.WriteString("[!reflect panic!]")
|
||||
}
|
||||
}()
|
||||
|
||||
val := reflect.ValueOf(arg)
|
||||
if val.Kind() == reflect.Ptr {
|
||||
if val.IsNil() {
|
||||
b.WriteString(nilString)
|
||||
return
|
||||
}
|
||||
val = val.Elem()
|
||||
}
|
||||
|
||||
switch val.Kind() {
|
||||
case reflect.Slice, reflect.Array:
|
||||
b.WriteByte('[')
|
||||
for i := 0; i < val.Len(); i++ {
|
||||
if i > 0 {
|
||||
b.WriteString(", ") // Use comma-space for readability
|
||||
}
|
||||
writeValue(b, val.Index(i).Interface(), depth+1)
|
||||
}
|
||||
b.WriteByte(']')
|
||||
|
||||
case reflect.Struct:
|
||||
typ := val.Type()
|
||||
b.WriteByte('{') // Use {} for structs to follow Go convention
|
||||
first := true
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
fieldValue := val.Field(i)
|
||||
if !fieldValue.CanInterface() {
|
||||
continue // Skip unexported fields
|
||||
}
|
||||
if !first {
|
||||
b.WriteByte(' ') // Use space as separator
|
||||
}
|
||||
first = false
|
||||
b.WriteString(typ.Field(i).Name)
|
||||
b.WriteByte(':')
|
||||
|
||||
writeValue(b, fieldValue.Interface(), depth+1)
|
||||
}
|
||||
b.WriteByte('}')
|
||||
|
||||
case reflect.Map:
|
||||
b.WriteByte('{')
|
||||
keys := val.MapKeys()
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
// A simple string-based sort for keys
|
||||
return fmt.Sprint(keys[i].Interface()) < fmt.Sprint(keys[j].Interface())
|
||||
})
|
||||
for i, key := range keys {
|
||||
if i > 0 {
|
||||
b.WriteByte(' ') // Use space as separator
|
||||
}
|
||||
writeValue(b, key.Interface(), depth+1)
|
||||
b.WriteByte(':')
|
||||
writeValue(b, val.MapIndex(key).Interface(), depth+1)
|
||||
}
|
||||
b.WriteByte('}')
|
||||
|
||||
case reflect.Interface:
|
||||
if val.IsNil() {
|
||||
b.WriteString(nilString)
|
||||
return
|
||||
}
|
||||
writeValue(b, val.Elem().Interface(), depth+1)
|
||||
|
||||
default:
|
||||
fmt.Fprint(b, arg)
|
||||
}
|
||||
}
|
||||
|
||||
// valueToString converts any value to a string representation.
|
||||
// It uses optimized paths for common types to avoid unnecessary allocations.
|
||||
// For types like integers and floats, it directly uses strconv functions.
|
||||
// This function is useful for single-argument conversions or as a helper in other parts of the package.
|
||||
// Unlike write, it returns a string instead of appending to a builder.
|
||||
func valueToString(arg any) string {
|
||||
switch v := arg.(type) {
|
||||
case string:
|
||||
return v
|
||||
case []byte:
|
||||
return bytesToString(v)
|
||||
case int:
|
||||
return strconv.Itoa(v)
|
||||
case int64:
|
||||
return strconv.FormatInt(v, 10)
|
||||
case int32:
|
||||
return strconv.FormatInt(int64(v), 10)
|
||||
case uint:
|
||||
return strconv.FormatUint(uint64(v), 10)
|
||||
case uint64:
|
||||
return strconv.FormatUint(v, 10)
|
||||
case float64:
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
case bool:
|
||||
if v {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
case fmt.Stringer:
|
||||
return v.String()
|
||||
case error:
|
||||
return v.Error()
|
||||
default:
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
|
||||
// estimateWith calculates a conservative estimate of the total string length when concatenating
|
||||
// the given arguments with a separator. This is used for preallocating capacity in strings.Builder
|
||||
// to minimize reallocations during building.
|
||||
// It accounts for the length of separators and estimates the length of each argument based on its type.
|
||||
// If no arguments are provided, it returns 0.
|
||||
func estimateWith(sep string, args []any) int {
|
||||
if len(args) == 0 {
|
||||
return 0
|
||||
}
|
||||
size := len(sep) * (len(args) - 1)
|
||||
size += estimate(args)
|
||||
return size
|
||||
}
|
||||
|
||||
// estimate calculates a conservative estimate of the combined string length of the given arguments.
|
||||
// It iterates over each argument and adds an estimated length based on its type:
|
||||
// - Strings and byte slices: exact length.
|
||||
// - Numbers: calculated digit count using numLen or uNumLen.
|
||||
// - Floats and others: fixed conservative estimates (e.g., 16 or 24 bytes).
|
||||
// This helper is used internally by estimateWith and focuses solely on the arguments without separators.
|
||||
func estimate(args []any) int {
|
||||
var size int
|
||||
for _, a := range args {
|
||||
switch v := a.(type) {
|
||||
case string:
|
||||
size += len(v)
|
||||
case []byte:
|
||||
size += len(v)
|
||||
case int:
|
||||
size += numLen(int64(v))
|
||||
case int8:
|
||||
size += numLen(int64(v))
|
||||
case int16:
|
||||
size += numLen(int64(v))
|
||||
case int32:
|
||||
size += numLen(int64(v))
|
||||
case int64:
|
||||
size += numLen(v)
|
||||
case uint:
|
||||
size += uNumLen(uint64(v))
|
||||
case uint8:
|
||||
size += uNumLen(uint64(v))
|
||||
case uint16:
|
||||
size += uNumLen(uint64(v))
|
||||
case uint32:
|
||||
size += uNumLen(uint64(v))
|
||||
case uint64:
|
||||
size += uNumLen(v)
|
||||
case float32:
|
||||
size += 16
|
||||
case float64:
|
||||
size += 24
|
||||
case bool:
|
||||
size += 5 // "false"
|
||||
case fmt.Stringer, error:
|
||||
size += 16 // conservative
|
||||
default:
|
||||
size += 16 // conservative
|
||||
}
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
// numLen returns the number of characters required to represent the signed integer n as a string.
|
||||
// It handles negative numbers by adding 1 for the '-' sign and uses a loop to count digits.
|
||||
// Special handling for math.MinInt64 to avoid overflow when negating.
|
||||
// Returns 1 for 0, and up to 20 for the largest values.
|
||||
func numLen(n int64) int {
|
||||
if n == 0 {
|
||||
return 1
|
||||
}
|
||||
c := 0
|
||||
if n < 0 {
|
||||
c = 1 // for '-'
|
||||
// NOTE: math.MinInt64 negated overflows; handle by adding one digit and returning 20.
|
||||
if n == -1<<63 {
|
||||
return 20
|
||||
}
|
||||
n = -n
|
||||
}
|
||||
for n > 0 {
|
||||
n /= 10
|
||||
c++
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// uNumLen returns the number of characters required to represent the unsigned integer n as a string.
|
||||
// It uses a loop to count digits.
|
||||
// Returns 1 for 0, and up to 20 for the largest uint64 values.
|
||||
func uNumLen(n uint64) int {
|
||||
if n == 0 {
|
||||
return 1
|
||||
}
|
||||
c := 0
|
||||
for n > 0 {
|
||||
n /= 10
|
||||
c++
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// bytesToString converts a byte slice to a string efficiently.
|
||||
// If the package's UnsafeBytes flag is set (via IsUnsafeBytes()), it uses unsafe operations
|
||||
// to create a string backed by the same memory as the byte slice, avoiding a copy.
|
||||
// This is zero-allocation when unsafe is enabled.
|
||||
// Falls back to standard string(bts) conversion otherwise.
|
||||
// For empty slices, it returns a constant empty string.
|
||||
// Compatible with Go 1.20+ unsafe functions like unsafe.String and unsafe.SliceData.
|
||||
func bytesToString(bts []byte) string {
|
||||
if len(bts) == 0 {
|
||||
return empty
|
||||
}
|
||||
if IsUnsafeBytes() {
|
||||
// Go 1.20+: unsafe.String with SliceData (1.20 introduced, 1.22 added SliceData).
|
||||
return unsafe.String(unsafe.SliceData(bts), len(bts))
|
||||
}
|
||||
return string(bts)
|
||||
}
|
||||
|
||||
// recursiveEstimate calculates the estimated string length for potentially nested arguments,
|
||||
// including the lengths of separators between elements. It recurses on nested []any slices,
|
||||
// flattening the structure while accounting for separators only between non-empty subparts.
|
||||
// This function is useful for preallocating capacity in builders for nested concatenation operations.
|
||||
func recursiveEstimate(sep string, args []any) int {
|
||||
if len(args) == 0 {
|
||||
return 0
|
||||
}
|
||||
size := 0
|
||||
needsSep := false
|
||||
for _, a := range args {
|
||||
switch v := a.(type) {
|
||||
case []any:
|
||||
subSize := recursiveEstimate(sep, v)
|
||||
if subSize > 0 {
|
||||
if needsSep {
|
||||
size += len(sep)
|
||||
}
|
||||
size += subSize
|
||||
needsSep = true
|
||||
}
|
||||
default:
|
||||
if needsSep {
|
||||
size += len(sep)
|
||||
}
|
||||
size += estimate([]any{a})
|
||||
needsSep = true
|
||||
}
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
// recursiveAdd appends the string representations of potentially nested arguments to the builder.
|
||||
// It recurses on nested []any slices, effectively flattening the structure by adding leaf values
|
||||
// directly via b.Add without inserting separators (separators are handled externally if needed).
|
||||
// This function is designed for efficient concatenation of nested argument lists.
|
||||
func recursiveAdd(b *Builder, args []any) {
|
||||
for _, a := range args {
|
||||
switch v := a.(type) {
|
||||
case []any:
|
||||
recursiveAdd(b, v)
|
||||
default:
|
||||
b.Add(a)
|
||||
}
|
||||
}
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
package cat
|
||||
|
||||
// On builds a SQL ON clause comparing two columns across tables.
|
||||
// Formats as: "table1.column1 = table2.column2" with proper spacing.
|
||||
// Useful in JOIN conditions to match keys between tables.
|
||||
func On(table1, column1, table2, column2 string) string {
|
||||
return With(space,
|
||||
With(dot, table1, column1),
|
||||
Pad(equal),
|
||||
With(dot, table2, column2),
|
||||
)
|
||||
}
|
||||
|
||||
// Using builds a SQL condition comparing two aliased columns.
|
||||
// Formats as: "alias1.column1 = alias2.column2" for JOINs or filters.
|
||||
// Helps when working with table aliases in complex queries.
|
||||
func Using(alias1, column1, alias2, column2 string) string {
|
||||
return With(space,
|
||||
With(dot, alias1, column1),
|
||||
Pad(equal),
|
||||
With(dot, alias2, column2),
|
||||
)
|
||||
}
|
||||
|
||||
// And joins multiple SQL conditions with the AND operator.
|
||||
// Adds spacing to ensure clean SQL output (e.g., "cond1 AND cond2").
|
||||
// Accepts variadic arguments for flexible condition chaining.
|
||||
func And(conditions ...any) string {
|
||||
return With(Pad(and), conditions...)
|
||||
}
|
||||
|
||||
// In creates a SQL IN clause with properly quoted values
|
||||
// Example: In("status", "active", "pending") → "status IN ('active', 'pending')"
|
||||
// Handles value quoting and comma separation automatically
|
||||
func In(column string, values ...string) string {
|
||||
if len(values) == 0 {
|
||||
return Concat(column, inOpen, inClose)
|
||||
}
|
||||
|
||||
quotedValues := make([]string, len(values))
|
||||
for i, v := range values {
|
||||
quotedValues[i] = "'" + v + "'"
|
||||
}
|
||||
return Concat(column, inOpen, JoinWith(comma+space, quotedValues...), inClose)
|
||||
}
|
||||
|
||||
// As creates an aliased SQL expression
|
||||
// Example: As("COUNT(*)", "total_count") → "COUNT(*) AS total_count"
|
||||
func As(expression, alias string) string {
|
||||
return Concat(expression, asSQL, alias)
|
||||
}
|
||||
|
||||
// Count creates a COUNT expression with optional alias
|
||||
// Example: Count("id") → "COUNT(id)"
|
||||
// Example: Count("id", "total") → "COUNT(id) AS total"
|
||||
// Example: Count("DISTINCT user_id", "unique_users") → "COUNT(DISTINCT user_id) AS unique_users"
|
||||
func Count(column string, alias ...string) string {
|
||||
expression := Concat(count, column, parenClose)
|
||||
if len(alias) == 0 {
|
||||
return expression
|
||||
}
|
||||
return As(expression, alias[0])
|
||||
}
|
||||
|
||||
// CountAll creates COUNT(*) with optional alias
|
||||
// Example: CountAll() → "COUNT(*)"
|
||||
// Example: CountAll("total") → "COUNT(*) AS total"
|
||||
func CountAll(alias ...string) string {
|
||||
if len(alias) == 0 {
|
||||
return countAll
|
||||
}
|
||||
return As(countAll, alias[0])
|
||||
}
|
||||
|
||||
// Sum creates a SUM expression with optional alias
|
||||
// Example: Sum("amount") → "SUM(amount)"
|
||||
// Example: Sum("amount", "total") → "SUM(amount) AS total"
|
||||
func Sum(column string, alias ...string) string {
|
||||
expression := Concat(sum, column, parenClose)
|
||||
if len(alias) == 0 {
|
||||
return expression
|
||||
}
|
||||
return As(expression, alias[0])
|
||||
}
|
||||
|
||||
// Avg creates an AVG expression with optional alias
|
||||
// Example: Avg("score") → "AVG(score)"
|
||||
// Example: Avg("score", "average") → "AVG(score) AS average"
|
||||
func Avg(column string, alias ...string) string {
|
||||
expression := Concat(avg, column, parenClose)
|
||||
if len(alias) == 0 {
|
||||
return expression
|
||||
}
|
||||
return As(expression, alias[0])
|
||||
}
|
||||
|
||||
// Max creates a MAX expression with optional alias
|
||||
// Example: Max("price") → "MAX(price)"
|
||||
// Example: Max("price", "max_price") → "MAX(price) AS max_price"
|
||||
func Max(column string, alias ...string) string {
|
||||
expression := Concat(maxOpen, column, parenClose)
|
||||
if len(alias) == 0 {
|
||||
return expression
|
||||
}
|
||||
return As(expression, alias[0])
|
||||
}
|
||||
|
||||
// Min creates a MIN expression with optional alias
|
||||
// Example: Min("price") → "MIN(price)"
|
||||
// Example: Min("price", "min_price") → "MIN(price) AS min_price"
|
||||
func Min(column string, alias ...string) string {
|
||||
expression := Concat(minOpen, column, parenClose)
|
||||
if len(alias) == 0 {
|
||||
return expression
|
||||
}
|
||||
return As(expression, alias[0])
|
||||
}
|
||||
|
||||
// Case creates a SQL CASE expression with optional alias
|
||||
// Example: Case("WHEN status = 'active' THEN 1 ELSE 0 END", "is_active") → "CASE WHEN status = 'active' THEN 1 ELSE 0 END AS is_active"
|
||||
func Case(expression string, alias ...string) string {
|
||||
caseExpr := Concat(caseSQL, expression)
|
||||
if len(alias) == 0 {
|
||||
return caseExpr
|
||||
}
|
||||
return As(caseExpr, alias[0])
|
||||
}
|
||||
|
||||
// CaseWhen creates a complete SQL CASE expression from individual parts with proper value handling
|
||||
// Example: CaseWhen("status =", "'active'", "1", "0", "is_active") → "CASE WHEN status = 'active' THEN 1 ELSE 0 END AS is_active"
|
||||
// Example: CaseWhen("age >", "18", "'adult'", "'minor'", "age_group") → "CASE WHEN age > 18 THEN 'adult' ELSE 'minor' END AS age_group"
|
||||
func CaseWhen(conditionPart string, conditionValue, thenValue, elseValue any, alias ...string) string {
|
||||
condition := Concat(conditionPart, valueToString(conditionValue))
|
||||
expression := Concat(
|
||||
when, condition, then, valueToString(thenValue), elseSQL, valueToString(elseValue), end,
|
||||
)
|
||||
return Case(expression, alias...)
|
||||
}
|
||||
|
||||
// CaseWhenMulti creates a SQL CASE expression with multiple WHEN clauses
|
||||
// Example: CaseWhenMulti([]string{"status =", "age >"}, []any{"'active'", 18}, []any{1, "'adult'"}, 0, "result") → "CASE WHEN status = 'active' THEN 1 WHEN age > 18 THEN 'adult' ELSE 0 END AS result"
|
||||
func CaseWhenMulti(conditionParts []string, conditionValues, thenValues []any, elseValue any, alias ...string) string {
|
||||
if len(conditionParts) != len(conditionValues) || len(conditionParts) != len(thenValues) {
|
||||
return "" // or handle error
|
||||
}
|
||||
|
||||
var whenClauses []string
|
||||
for i := 0; i < len(conditionParts); i++ {
|
||||
condition := Concat(conditionParts[i], valueToString(conditionValues[i]))
|
||||
whenClause := Concat(when, condition, then, valueToString(thenValues[i]))
|
||||
whenClauses = append(whenClauses, whenClause)
|
||||
}
|
||||
|
||||
expression := Concat(
|
||||
JoinWith(space, whenClauses...),
|
||||
elseSQL,
|
||||
valueToString(elseValue),
|
||||
end,
|
||||
)
|
||||
return Case(expression, alias...)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Created by .ignore support plugin (hsz.mobi)
|
||||
.idea/
|
||||
tmp/
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Oleku Konko
|
||||
|
||||
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:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
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.
|
||||
+1565
File diff suppressed because it is too large
Load Diff
+598
@@ -0,0 +1,598 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog" // Standard structured logging package
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Chain executes functions sequentially with enhanced error handling.
|
||||
// Logging is optional and configured via a slog.Handler.
|
||||
type Chain struct {
|
||||
steps []chainStep // List of steps to execute
|
||||
errors []error // Accumulated errors during execution
|
||||
config chainConfig // Chain-wide configuration
|
||||
lastStep *chainStep // Pointer to the last added step for configuration
|
||||
logHandler slog.Handler // Optional logging handler (nil means no logging)
|
||||
cancel context.CancelFunc // Function to cancel the context
|
||||
}
|
||||
|
||||
// chainStep represents a single step in the chain.
|
||||
type chainStep struct {
|
||||
execute func() error // Function to execute for this step
|
||||
optional bool // If true, errors don't stop the chain
|
||||
config stepConfig // Step-specific configuration
|
||||
}
|
||||
|
||||
// chainConfig holds chain-wide settings.
|
||||
type chainConfig struct {
|
||||
timeout time.Duration // Maximum duration for the entire chain
|
||||
maxErrors int // Maximum number of errors before stopping (-1 for unlimited)
|
||||
autoWrap bool // Whether to automatically wrap errors with additional context
|
||||
}
|
||||
|
||||
// stepConfig holds configuration for an individual step.
|
||||
type stepConfig struct {
|
||||
context map[string]interface{} // Arbitrary key-value pairs for context
|
||||
category ErrorCategory // Category for error classification
|
||||
code int // Numeric error code
|
||||
retry *Retry // Retry policy for the step
|
||||
logOnFail bool // Whether to log errors automatically
|
||||
metricsLabel string // Label for metrics (not used in this code)
|
||||
logAttrs []slog.Attr // Additional attributes for logging
|
||||
}
|
||||
|
||||
// ChainOption defines a function that configures a Chain.
|
||||
type ChainOption func(*Chain)
|
||||
|
||||
// NewChain creates a new Chain with the given options.
|
||||
// Logging is disabled by default (logHandler is nil).
|
||||
func NewChain(opts ...ChainOption) *Chain {
|
||||
c := &Chain{
|
||||
config: chainConfig{
|
||||
autoWrap: true, // Enable error wrapping by default
|
||||
maxErrors: -1, // No limit on errors by default
|
||||
},
|
||||
// logHandler is nil, meaning no logging unless explicitly configured
|
||||
}
|
||||
// Apply each configuration option
|
||||
for _, opt := range opts {
|
||||
opt(c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// ChainWithLogHandler sets a custom slog.Handler for logging.
|
||||
// If handler is nil, logging is effectively disabled.
|
||||
func ChainWithLogHandler(handler slog.Handler) ChainOption {
|
||||
return func(c *Chain) {
|
||||
c.logHandler = handler
|
||||
}
|
||||
}
|
||||
|
||||
// ChainWithTimeout sets a timeout for the entire chain.
|
||||
func ChainWithTimeout(d time.Duration) ChainOption {
|
||||
return func(c *Chain) {
|
||||
c.config.timeout = d
|
||||
}
|
||||
}
|
||||
|
||||
// ChainWithMaxErrors sets the maximum number of errors allowed.
|
||||
// A value <= 0 means no limit.
|
||||
func ChainWithMaxErrors(max int) ChainOption {
|
||||
return func(c *Chain) {
|
||||
if max <= 0 {
|
||||
c.config.maxErrors = -1 // No limit
|
||||
} else {
|
||||
c.config.maxErrors = max
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ChainWithAutoWrap enables or disables automatic error wrapping.
|
||||
func ChainWithAutoWrap(auto bool) ChainOption {
|
||||
return func(c *Chain) {
|
||||
c.config.autoWrap = auto
|
||||
}
|
||||
}
|
||||
|
||||
// Step adds a new step to the chain with the provided function.
|
||||
// The function must return an error or nil.
|
||||
func (c *Chain) Step(fn func() error) *Chain {
|
||||
if fn == nil {
|
||||
// Panic to enforce valid input
|
||||
panic("Chain.Step: provided function cannot be nil")
|
||||
}
|
||||
// Create a new step with default configuration
|
||||
step := chainStep{execute: fn, config: stepConfig{}}
|
||||
c.steps = append(c.steps, step)
|
||||
// Update lastStep to point to the newly added step
|
||||
c.lastStep = &c.steps[len(c.steps)-1]
|
||||
return c
|
||||
}
|
||||
|
||||
// Call adds a step by wrapping a function with arguments.
|
||||
// It uses reflection to validate and invoke the function.
|
||||
func (c *Chain) Call(fn interface{}, args ...interface{}) *Chain {
|
||||
// Wrap the function and arguments into an executable step
|
||||
wrappedFn, err := c.wrapCallable(fn, args...)
|
||||
if err != nil {
|
||||
// Panic on setup errors to catch them early
|
||||
panic(fmt.Sprintf("Chain.Call setup error: %v", err))
|
||||
}
|
||||
// Add the wrapped function as a step
|
||||
step := chainStep{execute: wrappedFn, config: stepConfig{}}
|
||||
c.steps = append(c.steps, step)
|
||||
c.lastStep = &c.steps[len(c.steps)-1]
|
||||
return c
|
||||
}
|
||||
|
||||
// Optional marks the last step as optional.
|
||||
// Optional steps don't stop the chain on error.
|
||||
func (c *Chain) Optional() *Chain {
|
||||
if c.lastStep == nil {
|
||||
// Panic if no step exists to mark as optional
|
||||
panic("Chain.Optional: must call Step() or Call() before Optional()")
|
||||
}
|
||||
c.lastStep.optional = true
|
||||
return c
|
||||
}
|
||||
|
||||
// WithLog adds logging attributes to the last step.
|
||||
func (c *Chain) WithLog(attrs ...slog.Attr) *Chain {
|
||||
if c.lastStep == nil {
|
||||
// Panic if no step exists to configure
|
||||
panic("Chain.WithLog: must call Step() or Call() before WithLog()")
|
||||
}
|
||||
// Append attributes to the step's logging configuration
|
||||
c.lastStep.config.logAttrs = append(c.lastStep.config.logAttrs, attrs...)
|
||||
return c
|
||||
}
|
||||
|
||||
// Timeout sets a timeout for the entire chain.
|
||||
func (c *Chain) Timeout(d time.Duration) *Chain {
|
||||
c.config.timeout = d
|
||||
return c
|
||||
}
|
||||
|
||||
// MaxErrors sets the maximum number of errors allowed.
|
||||
func (c *Chain) MaxErrors(max int) *Chain {
|
||||
if max <= 0 {
|
||||
c.config.maxErrors = -1 // No limit
|
||||
} else {
|
||||
c.config.maxErrors = max
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// With adds a key-value pair to the last step's context.
|
||||
func (c *Chain) With(key string, value interface{}) *Chain {
|
||||
if c.lastStep == nil {
|
||||
// Panic if no step exists to configure
|
||||
panic("Chain.With: must call Step() or Call() before With()")
|
||||
}
|
||||
// Initialize context map if nil
|
||||
if c.lastStep.config.context == nil {
|
||||
c.lastStep.config.context = make(map[string]interface{})
|
||||
}
|
||||
// Add the key-value pair
|
||||
c.lastStep.config.context[key] = value
|
||||
return c
|
||||
}
|
||||
|
||||
// Tag sets an error category for the last step.
|
||||
func (c *Chain) Tag(category ErrorCategory) *Chain {
|
||||
if c.lastStep == nil {
|
||||
// Panic if no step exists to configure
|
||||
panic("Chain.Tag: must call Step() or Call() before Tag()")
|
||||
}
|
||||
c.lastStep.config.category = category
|
||||
return c
|
||||
}
|
||||
|
||||
// Code sets a numeric error code for the last step.
|
||||
func (c *Chain) Code(code int) *Chain {
|
||||
if c.lastStep == nil {
|
||||
// Panic if no step exists to configure
|
||||
panic("Chain.Code: must call Step() or Call() before Code()")
|
||||
}
|
||||
c.lastStep.config.code = code
|
||||
return c
|
||||
}
|
||||
|
||||
// Retry configures retry behavior for the last step.
|
||||
// Retry configures retry behavior for the last step.
|
||||
func (c *Chain) Retry(maxAttempts int, delay time.Duration, opts ...RetryOption) *Chain {
|
||||
if c.lastStep == nil {
|
||||
panic("Chain.Retry: must call Step() or Call() before Retry()")
|
||||
}
|
||||
if maxAttempts < 1 {
|
||||
maxAttempts = 1
|
||||
}
|
||||
|
||||
// Define default retry options
|
||||
retryOpts := []RetryOption{
|
||||
WithMaxAttempts(maxAttempts),
|
||||
WithDelay(delay),
|
||||
WithRetryIf(func(err error) bool { return IsRetryable(err) }),
|
||||
}
|
||||
|
||||
// Add logging for retry attempts if a handler is configured
|
||||
if c.logHandler != nil {
|
||||
step := c.lastStep
|
||||
retryOpts = append(retryOpts, WithOnRetry(func(attempt int, err error) {
|
||||
// Prepare logging attributes
|
||||
logAttrs := []slog.Attr{
|
||||
slog.Int("attempt", attempt),
|
||||
slog.Int("max_attempts", maxAttempts),
|
||||
}
|
||||
// Enhance the error with step context
|
||||
enhancedErr := c.enhanceError(err, step)
|
||||
// Log the retry attempt
|
||||
c.logError(enhancedErr, fmt.Sprintf("Retrying step (attempt %d/%d)", attempt, maxAttempts), step.config, logAttrs...)
|
||||
}))
|
||||
}
|
||||
|
||||
// Append any additional retry options
|
||||
retryOpts = append(retryOpts, opts...)
|
||||
// Create and assign the retry configuration
|
||||
c.lastStep.config.retry = NewRetry(retryOpts...)
|
||||
return c
|
||||
}
|
||||
|
||||
// LogOnFail enables automatic logging of errors for the last step.
|
||||
func (c *Chain) LogOnFail() *Chain {
|
||||
if c.lastStep == nil {
|
||||
// Panic if no step exists to configure
|
||||
panic("Chain.LogOnFail: must call Step() or Call() before LogOnFail()")
|
||||
}
|
||||
c.lastStep.config.logOnFail = true
|
||||
return c
|
||||
}
|
||||
|
||||
// Run executes the chain, stopping on the first non-optional error.
|
||||
// It returns the first error encountered or nil if all steps succeed.
|
||||
func (c *Chain) Run() error {
|
||||
// Create a context with timeout or cancellation
|
||||
ctx, cancel := c.getContextAndCancel()
|
||||
defer cancel()
|
||||
c.cancel = cancel
|
||||
// Clear any previous errors
|
||||
c.errors = c.errors[:0]
|
||||
|
||||
// Execute each step in sequence
|
||||
for i := range c.steps {
|
||||
step := &c.steps[i]
|
||||
// Check if the context has been canceled
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
err := ctx.Err()
|
||||
// Enhance the error with step context
|
||||
enhancedErr := c.enhanceError(err, step)
|
||||
c.errors = append(c.errors, enhancedErr)
|
||||
// Log the context error
|
||||
c.logError(enhancedErr, "Chain stopped due to context error before step", step.config)
|
||||
return enhancedErr
|
||||
default:
|
||||
}
|
||||
|
||||
// Execute the step
|
||||
err := c.executeStep(ctx, step)
|
||||
if err != nil {
|
||||
// Enhance the error with step context
|
||||
enhancedErr := c.enhanceError(err, step)
|
||||
c.errors = append(c.errors, enhancedErr)
|
||||
// Log the error if required
|
||||
if step.config.logOnFail || !step.optional {
|
||||
logMsg := "Chain stopped due to error in step"
|
||||
if step.optional {
|
||||
logMsg = "Optional step failed"
|
||||
}
|
||||
c.logError(enhancedErr, logMsg, step.config)
|
||||
}
|
||||
// Stop execution if the step is not optional
|
||||
if !step.optional {
|
||||
return enhancedErr
|
||||
}
|
||||
}
|
||||
}
|
||||
// Return nil if all steps completed successfully
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunAll executes all steps, collecting errors without stopping.
|
||||
// It returns a MultiError containing all errors or nil if none occurred.
|
||||
func (c *Chain) RunAll() error {
|
||||
ctx, cancel := c.getContextAndCancel()
|
||||
defer cancel()
|
||||
c.cancel = cancel
|
||||
c.errors = c.errors[:0]
|
||||
multi := NewMultiError()
|
||||
|
||||
for i := range c.steps {
|
||||
step := &c.steps[i]
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
err := ctx.Err()
|
||||
enhancedErr := c.enhanceError(err, step)
|
||||
c.errors = append(c.errors, enhancedErr)
|
||||
multi.Add(enhancedErr)
|
||||
c.logError(enhancedErr, "Chain stopped due to context error before step (RunAll)", step.config)
|
||||
goto endRunAll
|
||||
default:
|
||||
}
|
||||
|
||||
err := c.executeStep(ctx, step)
|
||||
if err != nil {
|
||||
enhancedErr := c.enhanceError(err, step)
|
||||
c.errors = append(c.errors, enhancedErr)
|
||||
multi.Add(enhancedErr)
|
||||
if step.config.logOnFail && c.logHandler != nil {
|
||||
c.logError(enhancedErr, "Step failed during RunAll", step.config)
|
||||
}
|
||||
if c.config.maxErrors > 0 && multi.Count() >= c.config.maxErrors {
|
||||
if c.logHandler != nil {
|
||||
// Create a logger to log the max errors condition
|
||||
logger := slog.New(c.logHandler)
|
||||
logger.LogAttrs(
|
||||
context.Background(),
|
||||
slog.LevelError,
|
||||
fmt.Sprintf("Stopping RunAll after reaching max errors (%d)", c.config.maxErrors),
|
||||
slog.Int("max_errors", c.config.maxErrors),
|
||||
)
|
||||
}
|
||||
goto endRunAll
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
endRunAll:
|
||||
return multi.Single()
|
||||
}
|
||||
|
||||
// Errors returns a copy of the collected errors.
|
||||
func (c *Chain) Errors() []error {
|
||||
if len(c.errors) == 0 {
|
||||
return nil
|
||||
}
|
||||
// Create a copy to prevent external modification
|
||||
errs := make([]error, len(c.errors))
|
||||
copy(errs, c.errors)
|
||||
return errs
|
||||
}
|
||||
|
||||
// Len returns the number of steps in the chain.
|
||||
func (c *Chain) Len() int {
|
||||
return len(c.steps)
|
||||
}
|
||||
|
||||
// HasErrors checks if any errors were collected.
|
||||
func (c *Chain) HasErrors() bool {
|
||||
return len(c.errors) > 0
|
||||
}
|
||||
|
||||
// LastError returns the most recent error or nil if none exist.
|
||||
func (c *Chain) LastError() error {
|
||||
if len(c.errors) > 0 {
|
||||
return c.errors[len(c.errors)-1]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reset clears the chain's steps, errors, and context.
|
||||
func (c *Chain) Reset() {
|
||||
if c.cancel != nil {
|
||||
// Cancel any active context
|
||||
c.cancel()
|
||||
c.cancel = nil
|
||||
}
|
||||
// Clear steps and errors
|
||||
c.steps = c.steps[:0]
|
||||
c.errors = c.errors[:0]
|
||||
c.lastStep = nil
|
||||
}
|
||||
|
||||
// Unwrap returns the collected errors (alias for Errors).
|
||||
func (c *Chain) Unwrap() []error {
|
||||
return c.errors
|
||||
}
|
||||
|
||||
// getContextAndCancel creates a context based on the chain's timeout.
|
||||
// It returns a context and its cancellation function.
|
||||
func (c *Chain) getContextAndCancel() (context.Context, context.CancelFunc) {
|
||||
parentCtx := context.Background()
|
||||
if c.config.timeout > 0 {
|
||||
// Create a context with a timeout
|
||||
return context.WithTimeout(parentCtx, c.config.timeout)
|
||||
}
|
||||
// Create a cancellable context
|
||||
return context.WithCancel(parentCtx)
|
||||
}
|
||||
|
||||
// logError logs an error with step-specific context and attributes.
|
||||
// It only logs if a handler is configured and the error is non-nil.
|
||||
func (c *Chain) logError(err error, msg string, config stepConfig, additionalAttrs ...slog.Attr) {
|
||||
// Skip logging if no handler is set or error is nil
|
||||
if c == nil || c.logHandler == nil || err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Create a logger on demand using the configured handler
|
||||
logger := slog.New(c.logHandler)
|
||||
|
||||
// Initialize attributes with error and timestamp
|
||||
allAttrs := make([]slog.Attr, 0, 5+len(config.logAttrs)+len(additionalAttrs))
|
||||
allAttrs = append(allAttrs, slog.Any("error", err))
|
||||
allAttrs = append(allAttrs, slog.Time("timestamp", time.Now()))
|
||||
|
||||
// Add step-specific metadata
|
||||
if config.category != "" {
|
||||
allAttrs = append(allAttrs, slog.String("category", string(config.category)))
|
||||
}
|
||||
if config.code != 0 {
|
||||
allAttrs = append(allAttrs, slog.Int("code", config.code))
|
||||
}
|
||||
for k, v := range config.context {
|
||||
allAttrs = append(allAttrs, slog.Any(k, v))
|
||||
}
|
||||
allAttrs = append(allAttrs, config.logAttrs...)
|
||||
allAttrs = append(allAttrs, additionalAttrs...)
|
||||
|
||||
// Add stack trace and error name if the error is of type *Error
|
||||
if e, ok := err.(*Error); ok {
|
||||
if stack := e.Stack(); len(stack) > 0 {
|
||||
// Format stack trace, truncating if too long
|
||||
stackStr := "\n\t" + strings.Join(stack, "\n\t")
|
||||
if len(stackStr) > 1000 {
|
||||
stackStr = stackStr[:1000] + "..."
|
||||
}
|
||||
allAttrs = append(allAttrs, slog.String("stacktrace", stackStr))
|
||||
}
|
||||
if name := e.Name(); name != "" {
|
||||
allAttrs = append(allAttrs, slog.String("error_name", name))
|
||||
}
|
||||
}
|
||||
|
||||
// Log the error at ERROR level with all attributes
|
||||
// Use a defer to catch any panics during logging
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// Print to stdout to avoid infinite recursion
|
||||
fmt.Printf("ERROR: Recovered from panic during logging: %v\nAttributes: %v\n", r, allAttrs)
|
||||
}
|
||||
}()
|
||||
logger.LogAttrs(context.Background(), slog.LevelError, msg, allAttrs...)
|
||||
}
|
||||
|
||||
// wrapCallable wraps a function and its arguments into an executable step.
|
||||
// It uses reflection to validate the function and arguments.
|
||||
func (c *Chain) wrapCallable(fn interface{}, args ...interface{}) (func() error, error) {
|
||||
val := reflect.ValueOf(fn)
|
||||
typ := val.Type()
|
||||
|
||||
// Ensure the provided value is a function
|
||||
if typ.Kind() != reflect.Func {
|
||||
return nil, fmt.Errorf("provided 'fn' is not a function (got %T)", fn)
|
||||
}
|
||||
// Check if the number of arguments matches the function's signature
|
||||
if typ.NumIn() != len(args) {
|
||||
return nil, fmt.Errorf("function expects %d arguments, but %d were provided", typ.NumIn(), len(args))
|
||||
}
|
||||
|
||||
// Prepare argument values
|
||||
argVals := make([]reflect.Value, len(args))
|
||||
errorType := reflect.TypeOf((*error)(nil)).Elem()
|
||||
for i, arg := range args {
|
||||
expectedType := typ.In(i)
|
||||
var providedVal reflect.Value
|
||||
if arg != nil {
|
||||
providedVal = reflect.ValueOf(arg)
|
||||
// Check if the argument type is assignable to the expected type
|
||||
if !providedVal.Type().AssignableTo(expectedType) {
|
||||
// Special case for error interfaces
|
||||
if expectedType.Kind() == reflect.Interface && expectedType.Implements(errorType) && providedVal.Type().Implements(errorType) {
|
||||
// Allow error interface
|
||||
} else {
|
||||
return nil, fmt.Errorf("argument %d type mismatch: expected %s, got %s", i, expectedType, providedVal.Type())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Handle nil arguments for nullable types
|
||||
switch expectedType.Kind() {
|
||||
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
|
||||
providedVal = reflect.Zero(expectedType)
|
||||
default:
|
||||
return nil, fmt.Errorf("argument %d is nil, but expected non-nillable type %s", i, expectedType)
|
||||
}
|
||||
}
|
||||
argVals[i] = providedVal
|
||||
}
|
||||
|
||||
// Validate the function's return type
|
||||
if typ.NumOut() > 1 || (typ.NumOut() == 1 && !typ.Out(0).Implements(errorType)) {
|
||||
return nil, fmt.Errorf("function must return either no values or a single error (got %d return values)", typ.NumOut())
|
||||
}
|
||||
|
||||
// Return a wrapped function that calls the original with the provided arguments
|
||||
return func() error {
|
||||
results := val.Call(argVals)
|
||||
if len(results) == 1 && results[0].Interface() != nil {
|
||||
return results[0].Interface().(error)
|
||||
}
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
// executeStep runs a single step, applying retries if configured.
|
||||
// This version is synchronous and avoids the bugs caused by the previous goroutine-based implementation.
|
||||
func (c *Chain) executeStep(ctx context.Context, step *chainStep) error {
|
||||
// First, check if the context has already been canceled before starting the step.
|
||||
// This allows the chain to fail fast.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
// Context is still active, proceed.
|
||||
}
|
||||
|
||||
// If the step has retry logic configured...
|
||||
if step.config.retry != nil {
|
||||
// Create a new retry instance that is aware of the chain's context.
|
||||
// The retry executor will be responsible for checking ctx.Done() between attempts.
|
||||
retryExecutor := step.config.retry.Transform(WithContext(ctx))
|
||||
|
||||
// Execute the step's function directly. The retry mechanism will manage the loop,
|
||||
// delays, and context cancellation checks. We pass step.execute without any
|
||||
// extra goroutine wrappers.
|
||||
return retryExecutor.Execute(step.execute)
|
||||
}
|
||||
|
||||
// For a simple, non-retrying step, execute the function directly and synchronously
|
||||
// in the current goroutine. This is the simplest, fastest, and most correct approach.
|
||||
// It ensures that database connections are used and returned to the pool sequentially,
|
||||
// preventing the deadlock issue.
|
||||
return step.execute()
|
||||
}
|
||||
|
||||
// enhanceError wraps an error with additional context from the step.
|
||||
func (c *Chain) enhanceError(err error, step *chainStep) error {
|
||||
if err == nil || !c.config.autoWrap {
|
||||
// Return the error unchanged if nil or autoWrap is disabled
|
||||
return err
|
||||
}
|
||||
|
||||
// Initialize the base error
|
||||
var baseError *Error
|
||||
if e, ok := err.(*Error); ok {
|
||||
// Copy existing *Error to preserve its properties
|
||||
baseError = e.Copy()
|
||||
} else {
|
||||
// Create a new *Error wrapping the original
|
||||
baseError = New(err.Error()).Wrap(err).WithStack()
|
||||
}
|
||||
|
||||
if step != nil {
|
||||
// Add step-specific context to the error
|
||||
if step.config.category != "" && baseError.Category() == "" {
|
||||
baseError.WithCategory(step.config.category)
|
||||
}
|
||||
if step.config.code != 0 && baseError.Code() == 0 {
|
||||
baseError.WithCode(step.config.code)
|
||||
}
|
||||
for k, v := range step.config.context {
|
||||
baseError.With(k, v)
|
||||
}
|
||||
for _, attr := range step.config.logAttrs {
|
||||
baseError.With(attr.Key, attr.Value.Any())
|
||||
}
|
||||
if step.config.retry != nil && !baseError.HasContextKey(ctxRetry) {
|
||||
// Mark the error as retryable if retries are configured
|
||||
baseError.WithRetryable()
|
||||
}
|
||||
}
|
||||
|
||||
return baseError
|
||||
}
|
||||
+1474
File diff suppressed because it is too large
Load Diff
+437
@@ -0,0 +1,437 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// As wraps errors.As, using custom type assertion for *Error types.
|
||||
// Falls back to standard errors.As for non-*Error types.
|
||||
// Returns false if either err or target is nil.
|
||||
func As(err error, target interface{}) bool {
|
||||
if err == nil || target == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// First try our custom *Error handling
|
||||
if e, ok := err.(*Error); ok {
|
||||
return e.As(target)
|
||||
}
|
||||
|
||||
// Fall back to standard errors.As
|
||||
return errors.As(err, target)
|
||||
}
|
||||
|
||||
// Code returns the status code of an error, if it is an *Error.
|
||||
// Returns 500 as a default for non-*Error types to indicate an internal error.
|
||||
func Code(err error) int {
|
||||
if e, ok := err.(*Error); ok {
|
||||
return e.Code()
|
||||
}
|
||||
return DefaultCode
|
||||
}
|
||||
|
||||
// Context extracts the context map from an error, if it is an *Error.
|
||||
// Returns nil for non-*Error types or if no context is present.
|
||||
func Context(err error) map[string]interface{} {
|
||||
if e, ok := err.(*Error); ok {
|
||||
return e.Context()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Convert transforms any error into an *Error, preserving its message and wrapping it if needed.
|
||||
// Returns nil if the input is nil; returns the original if already an *Error.
|
||||
// Uses multiple strategies: direct assertion, errors.As, manual unwrapping, and fallback creation.
|
||||
func Convert(err error) *Error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// First try direct type assertion (fast path)
|
||||
if e, ok := err.(*Error); ok {
|
||||
return e
|
||||
}
|
||||
|
||||
// Try using errors.As (more flexible)
|
||||
var e *Error
|
||||
if errors.As(err, &e) {
|
||||
return e
|
||||
}
|
||||
|
||||
// Manual unwrapping as fallback
|
||||
visited := make(map[error]bool)
|
||||
for unwrapped := err; unwrapped != nil; {
|
||||
if visited[unwrapped] {
|
||||
break // Cycle detected
|
||||
}
|
||||
visited[unwrapped] = true
|
||||
if e, ok := unwrapped.(*Error); ok {
|
||||
return e
|
||||
}
|
||||
unwrapped = errors.Unwrap(unwrapped)
|
||||
}
|
||||
|
||||
// Final fallback: create new error with original message and wrap it
|
||||
return New(err.Error()).Wrap(err)
|
||||
}
|
||||
|
||||
// Count returns the occurrence count of an error, if it is an *Error.
|
||||
// Returns 0 for non-*Error types.
|
||||
func Count(err error) uint64 {
|
||||
if e, ok := err.(*Error); ok {
|
||||
return e.Count()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Find searches the error chain for the first error matching pred.
|
||||
// Returns nil if no match is found or pred is nil; traverses both Unwrap() and Cause() chains.
|
||||
func Find(err error, pred func(error) bool) error {
|
||||
for current := err; current != nil; {
|
||||
if pred(current) {
|
||||
return current
|
||||
}
|
||||
|
||||
// Attempt to unwrap using Unwrap() or Cause()
|
||||
switch v := current.(type) {
|
||||
case interface{ Unwrap() error }:
|
||||
current = v.Unwrap()
|
||||
case interface{ Cause() error }:
|
||||
current = v.Cause()
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// From transforms any error into an *Error, preserving its message and wrapping it if needed.
|
||||
// Alias of Convert; returns nil if input is nil, original if already an *Error.
|
||||
func From(err error) *Error {
|
||||
return Convert(err)
|
||||
}
|
||||
|
||||
// FromContext creates an *Error from a context and an existing error.
|
||||
// Enhances the error with context info: timeout status, deadline, or cancellation.
|
||||
// Returns nil if input error is nil; does not store context values directly.
|
||||
func FromContext(ctx context.Context, err error) *Error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
e := New(err.Error())
|
||||
|
||||
// Handle context errors
|
||||
switch ctx.Err() {
|
||||
case context.DeadlineExceeded:
|
||||
e.WithTimeout()
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
e.With("deadline", deadline.Format(time.RFC3339))
|
||||
}
|
||||
case context.Canceled:
|
||||
e.With("cancelled", true)
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// Category returns the category of an error, if it is an *Error.
|
||||
// Returns an empty string for non-*Error types or unset categories.
|
||||
func Category(err error) string {
|
||||
if e, ok := err.(*Error); ok {
|
||||
return e.category
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Has checks if an error contains meaningful content.
|
||||
// Returns true for non-nil standard errors or *Error with content (msg, name, template, or cause).
|
||||
func Has(err error) bool {
|
||||
if e, ok := err.(*Error); ok {
|
||||
return e.Has()
|
||||
}
|
||||
return err != nil
|
||||
}
|
||||
|
||||
// HasContextKey checks if the error's context contains the specified key.
|
||||
// Returns false for non-*Error types or if the key is not present in the context.
|
||||
func HasContextKey(err error, key string) bool {
|
||||
if e, ok := err.(*Error); ok {
|
||||
ctx := e.Context()
|
||||
if ctx != nil {
|
||||
_, exists := ctx[key]
|
||||
return exists
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Is wraps errors.Is, using custom matching for *Error types.
|
||||
// Falls back to standard errors.Is for non-*Error types; returns true if err equals target.
|
||||
func Is(err, target error) bool {
|
||||
if err == nil || target == nil {
|
||||
return err == target
|
||||
}
|
||||
|
||||
if e, ok := err.(*Error); ok {
|
||||
return e.Is(target)
|
||||
}
|
||||
|
||||
// Use standard errors.Is for non-Error types
|
||||
return errors.Is(err, target)
|
||||
}
|
||||
|
||||
// IsError checks if an error is an instance of *Error.
|
||||
// Returns true only for this package's custom error type; false for nil or other types.
|
||||
func IsError(err error) bool {
|
||||
_, ok := err.(*Error)
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsEmpty checks if an error has no meaningful content.
|
||||
// Returns true for nil errors, empty *Error instances, or standard errors with whitespace-only messages.
|
||||
func IsEmpty(err error) bool {
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
if e, ok := err.(*Error); ok {
|
||||
return e.IsEmpty()
|
||||
}
|
||||
return strings.TrimSpace(err.Error()) == ""
|
||||
}
|
||||
|
||||
// IsNull checks if an error is nil or represents a NULL value.
|
||||
// Delegates to *Error’s IsNull for custom errors; uses sqlNull for others.
|
||||
func IsNull(err error) bool {
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
if e, ok := err.(*Error); ok {
|
||||
return e.IsNull()
|
||||
}
|
||||
return sqlNull(err)
|
||||
}
|
||||
|
||||
// IsRetryable checks if an error is retryable.
|
||||
// For *Error, checks context for retry flag; for others, looks for "retry" or timeout in message.
|
||||
// Returns false for nil errors; thread-safe for *Error types.
|
||||
func IsRetryable(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if e, ok := err.(*Error); ok {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
// Check smallContext directly if context map isn’t populated
|
||||
for i := int32(0); i < e.smallCount; i++ {
|
||||
if e.smallContext[i].key == ctxRetry {
|
||||
if val, ok := e.smallContext[i].value.(bool); ok {
|
||||
return val
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check regular context
|
||||
if e.context != nil {
|
||||
if val, ok := e.context[ctxRetry].(bool); ok {
|
||||
return val
|
||||
}
|
||||
}
|
||||
// Check cause recursively
|
||||
if e.cause != nil {
|
||||
return IsRetryable(e.cause)
|
||||
}
|
||||
}
|
||||
lowerMsg := strings.ToLower(err.Error())
|
||||
return IsTimeout(err) || strings.Contains(lowerMsg, "retry")
|
||||
}
|
||||
|
||||
// IsTimeout checks if an error indicates a timeout.
|
||||
// For *Error, checks context for timeout flag; for others, looks for "timeout" in message.
|
||||
// Returns false for nil errors.
|
||||
func IsTimeout(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if e, ok := err.(*Error); ok {
|
||||
if val, ok := e.Context()[ctxTimeout].(bool); ok {
|
||||
return val
|
||||
}
|
||||
}
|
||||
return strings.Contains(strings.ToLower(err.Error()), "timeout")
|
||||
}
|
||||
|
||||
// Merge combines multiple errors into a single *Error.
|
||||
// Aggregates messages with "; " separator, merges contexts and stacks; returns nil if no errors provided.
|
||||
func Merge(errs ...error) *Error {
|
||||
if len(errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
var messages []string
|
||||
combined := New("")
|
||||
for _, err := range errs {
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
messages = append(messages, err.Error())
|
||||
if e, ok := err.(*Error); ok {
|
||||
if e.stack != nil && combined.stack == nil {
|
||||
combined.WithStack() // Capture stack from first *Error with stack
|
||||
}
|
||||
if ctx := e.Context(); ctx != nil {
|
||||
for k, v := range ctx {
|
||||
combined.With(k, v)
|
||||
}
|
||||
}
|
||||
if e.cause != nil {
|
||||
combined.Wrap(e.cause)
|
||||
}
|
||||
} else {
|
||||
combined.Wrap(err)
|
||||
}
|
||||
}
|
||||
if len(messages) > 0 {
|
||||
combined.msg = strings.Join(messages, "; ")
|
||||
}
|
||||
return combined
|
||||
}
|
||||
|
||||
// Name returns the name of an error, if it is an *Error.
|
||||
// Returns an empty string for non-*Error types or unset names.
|
||||
func Name(err error) string {
|
||||
if e, ok := err.(*Error); ok {
|
||||
return e.name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// UnwrapAll returns a slice of all errors in the chain, including the root error.
|
||||
// Traverses both Unwrap() and Cause() chains; returns nil if err is nil.
|
||||
func UnwrapAll(err error) []error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if e, ok := err.(*Error); ok {
|
||||
return e.UnwrapAll()
|
||||
}
|
||||
var result []error
|
||||
Walk(err, func(e error) {
|
||||
result = append(result, e)
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// Stack extracts the stack trace from an error, if it is an *Error.
|
||||
// Returns nil for non-*Error types or if no stack is present.
|
||||
func Stack(err error) []string {
|
||||
if e, ok := err.(*Error); ok {
|
||||
return e.Stack()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Transform applies transformations to an error, returning a new *Error.
|
||||
// Creates a new *Error from non-*Error types before applying fn; returns nil if err is nil.
|
||||
func Transform(err error, fn func(*Error)) *Error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if e, ok := err.(*Error); ok {
|
||||
newErr := e.Copy()
|
||||
fn(newErr)
|
||||
return newErr
|
||||
}
|
||||
// If not an *Error, create a new one and transform it
|
||||
newErr := New(err.Error())
|
||||
fn(newErr)
|
||||
return newErr
|
||||
}
|
||||
|
||||
// Unwrap returns the underlying cause of an error, if it implements Unwrap.
|
||||
// For *Error, returns cause; for others, returns the error itself; nil if err is nil.
|
||||
func Unwrap(err error) error {
|
||||
for current := err; current != nil; {
|
||||
if e, ok := current.(*Error); ok {
|
||||
if e.cause == nil {
|
||||
return current
|
||||
}
|
||||
current = e.cause
|
||||
} else {
|
||||
return current
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Walk traverses the error chain, applying fn to each error.
|
||||
// Supports both Unwrap() and Cause() interfaces; stops at nil or non-unwrappable errors.
|
||||
func Walk(err error, fn func(error)) {
|
||||
for current := err; current != nil; {
|
||||
fn(current)
|
||||
|
||||
// Attempt to unwrap using Unwrap() or Cause()
|
||||
switch v := current.(type) {
|
||||
case interface{ Unwrap() error }:
|
||||
current = v.Unwrap()
|
||||
case interface{ Cause() error }:
|
||||
current = v.Cause()
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// With adds a key-value pair to an error's context, if it is an *Error.
|
||||
// Returns the original error unchanged if not an *Error; no-op for non-*Error types.
|
||||
func With(err error, key string, value interface{}) error {
|
||||
if e, ok := err.(*Error); ok {
|
||||
return e.With(key, value)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// WithStack converts any error to an *Error and captures a stack trace.
|
||||
// Returns nil if input is nil; adds stack to existing *Error or wraps non-*Error types.
|
||||
func WithStack(err error) *Error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if e, ok := err.(*Error); ok {
|
||||
return e.WithStack()
|
||||
}
|
||||
return New(err.Error()).WithStack().Wrap(err)
|
||||
}
|
||||
|
||||
// Wrap creates a new *Error that wraps another error with additional context.
|
||||
// Uses a copy of the provided wrapper *Error; returns nil if err is nil.
|
||||
func Wrap(err error, wrapper *Error) *Error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if wrapper == nil {
|
||||
wrapper = newError()
|
||||
}
|
||||
newErr := wrapper.Copy()
|
||||
newErr.cause = err
|
||||
return newErr
|
||||
}
|
||||
|
||||
// Wrapf creates a new formatted *Error that wraps another error.
|
||||
// Formats the message and sets the cause; returns nil if err is nil.
|
||||
func Wrapf(err error, format string, args ...interface{}) *Error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
e := newError()
|
||||
e.msg = fmt.Sprintf(format, args...)
|
||||
e.cause = err
|
||||
return e
|
||||
}
|
||||
|
||||
// Err creates a new Error with the given message and wraps the provided error as its cause.
|
||||
func Err(msg string, err error) *Error {
|
||||
return New(msg).Wrap(err)
|
||||
}
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
// File: inspect.go
|
||||
// Updated to support both error and *Error with delegation for cleaner *Error handling
|
||||
|
||||
package errors
|
||||
|
||||
import (
|
||||
stderrs "errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Inspect provides detailed examination of an error, handling both single errors and MultiError
|
||||
func Inspect(err error) {
|
||||
if err == nil {
|
||||
fmt.Println("No error occurred")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("\n=== Error Inspection ===\n")
|
||||
fmt.Printf("Top-level error: %v\n", err)
|
||||
fmt.Printf("Top-level error type: %T\n", err)
|
||||
|
||||
// Handle *Error directly
|
||||
if e, ok := err.(*Error); ok {
|
||||
InspectError(e)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle MultiError
|
||||
if multi, ok := err.(*MultiError); ok {
|
||||
allErrors := multi.Errors()
|
||||
fmt.Printf("\nContains %d errors:\n", len(allErrors))
|
||||
for i, e := range allErrors {
|
||||
fmt.Printf("\n--- Error %d ---\n", i+1)
|
||||
inspectSingleError(e)
|
||||
}
|
||||
} else {
|
||||
// Inspect single error if not MultiError or *Error
|
||||
fmt.Println("\n--- Details ---")
|
||||
inspectSingleError(err)
|
||||
}
|
||||
|
||||
// Additional diagnostics
|
||||
fmt.Println("\n--- Diagnostics ---")
|
||||
if IsRetryable(err) {
|
||||
fmt.Println("- Error chain contains retryable errors")
|
||||
}
|
||||
if IsTimeout(err) {
|
||||
fmt.Println("- Error chain contains timeout errors")
|
||||
}
|
||||
if code := getErrorCode(err); code != 0 {
|
||||
fmt.Printf("- Highest priority error code: %d\n", code)
|
||||
}
|
||||
fmt.Printf("========================\n\n")
|
||||
}
|
||||
|
||||
// InspectError provides detailed inspection of a specific *Error instance
|
||||
func InspectError(err *Error) {
|
||||
if err == nil {
|
||||
fmt.Println("No error occurred")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("\n=== Error Inspection (*Error) ===\n")
|
||||
fmt.Printf("Top-level error: %v\n", err)
|
||||
fmt.Printf("Top-level error type: %T\n", err)
|
||||
|
||||
fmt.Println("\n--- Details ---")
|
||||
inspectSingleError(err) // Delegate to handle unwrapping and details
|
||||
|
||||
// Additional diagnostics specific to *Error
|
||||
fmt.Println("\n--- Diagnostics ---")
|
||||
if IsRetryable(err) {
|
||||
fmt.Println("- Error is retryable")
|
||||
}
|
||||
if IsTimeout(err) {
|
||||
fmt.Println("- Error chain contains timeout errors")
|
||||
}
|
||||
if code := err.Code(); code != 0 {
|
||||
fmt.Printf("- Error code: %d\n", code)
|
||||
}
|
||||
fmt.Printf("========================\n\n")
|
||||
}
|
||||
|
||||
// inspectSingleError handles inspection of a single error (may be part of a chain)
|
||||
func inspectSingleError(err error) {
|
||||
if err == nil {
|
||||
fmt.Println(" (nil error)")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf(" Error: %v\n", err)
|
||||
fmt.Printf(" Type: %T\n", err)
|
||||
|
||||
// Handle wrapped errors, including *Error type
|
||||
var currentErr error = err
|
||||
depth := 0
|
||||
for currentErr != nil {
|
||||
prefix := strings.Repeat(" ", depth+1)
|
||||
if depth > 0 {
|
||||
fmt.Printf("%sWrapped Cause (%T): %v\n", prefix, currentErr, currentErr)
|
||||
}
|
||||
|
||||
// Check if it's our specific *Error type
|
||||
if e, ok := currentErr.(*Error); ok {
|
||||
if name := e.Name(); name != "" {
|
||||
fmt.Printf("%sName: %s\n", prefix, name)
|
||||
}
|
||||
if cat := e.Category(); cat != "" {
|
||||
fmt.Printf("%sCategory: %s\n", prefix, cat)
|
||||
}
|
||||
if code := e.Code(); code != 0 {
|
||||
fmt.Printf("%sCode: %d\n", prefix, code)
|
||||
}
|
||||
if ctx := e.Context(); len(ctx) > 0 {
|
||||
fmt.Printf("%sContext:\n", prefix)
|
||||
for k, v := range ctx {
|
||||
fmt.Printf("%s %s: %v\n", prefix, k, v)
|
||||
}
|
||||
}
|
||||
if stack := e.Stack(); len(stack) > 0 {
|
||||
fmt.Printf("%sStack (Top 3):\n", prefix)
|
||||
limit := 3
|
||||
if len(stack) < limit {
|
||||
limit = len(stack)
|
||||
}
|
||||
for i := 0; i < limit; i++ {
|
||||
fmt.Printf("%s %s\n", prefix, stack[i])
|
||||
}
|
||||
if len(stack) > limit {
|
||||
fmt.Printf("%s ... (%d more frames)\n", prefix, len(stack)-limit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unwrap using standard errors.Unwrap and handle *Error Unwrap
|
||||
var nextErr error
|
||||
// Prioritize *Error's Unwrap if available AND it returns non-nil
|
||||
if e, ok := currentErr.(*Error); ok {
|
||||
unwrapped := e.Unwrap()
|
||||
if unwrapped != nil {
|
||||
nextErr = unwrapped
|
||||
} else {
|
||||
// If *Error.Unwrap returns nil, fall back to standard unwrap
|
||||
// This handles cases where *Error might wrap a non-standard error
|
||||
// or where its internal cause is deliberately nil.
|
||||
nextErr = stderrs.Unwrap(currentErr)
|
||||
}
|
||||
} else {
|
||||
nextErr = stderrs.Unwrap(currentErr) // Fall back to standard unwrap for non-*Error types
|
||||
}
|
||||
|
||||
// Prevent infinite loops if Unwrap returns the same error, or stop if no more unwrapping
|
||||
if nextErr == currentErr || nextErr == nil {
|
||||
break
|
||||
}
|
||||
currentErr = nextErr
|
||||
depth++
|
||||
if depth > 10 { // Safety break for very deep or potentially cyclic chains
|
||||
fmt.Printf("%s... (chain too deep or potential cycle)\n", strings.Repeat(" ", depth+1))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getErrorCode traverses the error chain to find the highest priority code.
|
||||
// It uses errors.As to find the first *Error in the chain.
|
||||
func getErrorCode(err error) int {
|
||||
var code int = 0 // Default code
|
||||
var target *Error
|
||||
if As(err, &target) { // Use the package's As helper
|
||||
if target != nil { // Add nil check for safety
|
||||
code = target.Code()
|
||||
}
|
||||
}
|
||||
// If the top-level error is *Error and has a code, it might take precedence.
|
||||
// This depends on desired logic. Let's keep it simple for now: first code found by As.
|
||||
if code == 0 { // Only check top-level if As didn't find one with a code
|
||||
if e, ok := err.(*Error); ok {
|
||||
code = e.Code()
|
||||
}
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
// handleError demonstrates using Inspect with additional handling logic
|
||||
func handleError(err error) {
|
||||
fmt.Println("\n=== Processing Failure ===")
|
||||
Inspect(err) // Use the primary Inspect function
|
||||
|
||||
// Additional handling based on inspection
|
||||
code := getErrorCode(err) // Use the helper
|
||||
|
||||
switch {
|
||||
case IsTimeout(err):
|
||||
fmt.Println("\nAction: Check connectivity or increase timeout")
|
||||
case code == 402: // Check code obtained via helper
|
||||
fmt.Println("\nAction: Payment processing failed - notify billing")
|
||||
default:
|
||||
fmt.Println("\nAction: Generic failure handling")
|
||||
}
|
||||
}
|
||||
|
||||
// processOrder demonstrates Chain usage with Inspect
|
||||
func processOrder() error {
|
||||
validateInput := func() error { return nil }
|
||||
processPayment := func() error { return stderrs.New("credit card declined") }
|
||||
sendNotification := func() error { fmt.Println("Notification sent."); return nil }
|
||||
logOrder := func() error { fmt.Println("Order logged."); return nil }
|
||||
|
||||
chain := NewChain(ChainWithTimeout(2*time.Second)).
|
||||
Step(validateInput).Tag("validation").
|
||||
Step(processPayment).Tag("billing").Code(402).Retry(3, 100*time.Millisecond, WithRetryIf(IsRetryable)).
|
||||
Step(sendNotification).Optional().
|
||||
Step(logOrder)
|
||||
|
||||
err := chain.Run()
|
||||
if err != nil {
|
||||
handleError(err) // Call the unified error handler
|
||||
return err // Propagate the error if needed
|
||||
}
|
||||
fmt.Println("Order processed successfully!")
|
||||
return nil
|
||||
}
|
||||
+423
@@ -0,0 +1,423 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// MultiError represents a thread-safe collection of errors with enhanced features.
|
||||
// Supports limits, sampling, and custom formatting for error aggregation.
|
||||
type MultiError struct {
|
||||
errors []error
|
||||
mu sync.RWMutex
|
||||
|
||||
// Configuration fields
|
||||
limit int // Maximum number of errors to store (0 = unlimited)
|
||||
formatter ErrorFormatter // Custom formatting function for error string
|
||||
sampling bool // Whether sampling is enabled to limit error collection
|
||||
sampleRate uint32 // Sampling percentage (1-100) when sampling is enabled
|
||||
rand *rand.Rand // Random source for sampling (nil defaults to fastRand)
|
||||
}
|
||||
|
||||
// ErrorFormatter defines a function for custom error message formatting.
|
||||
// Takes a slice of errors and returns a single formatted string.
|
||||
type ErrorFormatter func([]error) string
|
||||
|
||||
// MultiErrorOption configures MultiError behavior during creation.
|
||||
type MultiErrorOption func(*MultiError)
|
||||
|
||||
// NewMultiError creates a new MultiError instance with optional configuration.
|
||||
// Initial capacity is set to 4; applies options in the order provided.
|
||||
func NewMultiError(opts ...MultiErrorOption) *MultiError {
|
||||
m := &MultiError{
|
||||
errors: make([]error, 0, 4),
|
||||
limit: 0, // Unlimited by default
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(m)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Add appends an error to the collection with optional sampling, limit checks, and duplicate prevention.
|
||||
// Ignores nil errors and duplicates based on string equality; thread-safe.
|
||||
func (m *MultiError) Add(errs ...error) {
|
||||
if len(errs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for _, err := range errs {
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for duplicates by comparing error messages
|
||||
duplicate := false
|
||||
for _, e := range m.errors {
|
||||
if e.Error() == err.Error() {
|
||||
duplicate = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if duplicate {
|
||||
continue
|
||||
}
|
||||
|
||||
// Apply sampling if enabled and collection isn’t empty
|
||||
if m.sampling && len(m.errors) > 0 {
|
||||
var r uint32
|
||||
if m.rand != nil {
|
||||
r = uint32(m.rand.Int31n(100))
|
||||
} else {
|
||||
r = fastRand() % 100
|
||||
}
|
||||
if r > m.sampleRate { // Accept if random value is within sample rate
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Respect limit if set
|
||||
if m.limit > 0 && len(m.errors) >= m.limit {
|
||||
continue
|
||||
}
|
||||
|
||||
m.errors = append(m.errors, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Addf formats and adds a new error to the collection.
|
||||
func (m *MultiError) Addf(format string, args ...interface{}) {
|
||||
m.Add(Newf(format, args...))
|
||||
}
|
||||
|
||||
// Clear removes all errors from the collection.
|
||||
// Thread-safe; resets the slice while preserving capacity.
|
||||
func (m *MultiError) Clear() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.errors = m.errors[:0]
|
||||
}
|
||||
|
||||
// Count returns the number of errors in the collection.
|
||||
// Thread-safe.
|
||||
func (m *MultiError) Count() int {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return len(m.errors)
|
||||
}
|
||||
|
||||
// Error returns a formatted string representation of the errors.
|
||||
// Returns empty string if no errors, single error message if one exists,
|
||||
// or a formatted list using custom formatter or default if multiple; thread-safe.
|
||||
func (m *MultiError) Error() string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
switch len(m.errors) {
|
||||
case 0:
|
||||
return ""
|
||||
case 1:
|
||||
return m.errors[0].Error()
|
||||
default:
|
||||
if m.formatter != nil {
|
||||
return m.formatter(m.errors)
|
||||
}
|
||||
return defaultFormat(m.errors)
|
||||
}
|
||||
}
|
||||
|
||||
// Errors returns a copy of the contained errors.
|
||||
// Thread-safe; returns nil if no errors exist.
|
||||
func (m *MultiError) Errors() []error {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
if len(m.errors) == 0 {
|
||||
return nil
|
||||
}
|
||||
errs := make([]error, len(m.errors))
|
||||
copy(errs, m.errors)
|
||||
return errs
|
||||
}
|
||||
|
||||
// Filter returns a new MultiError containing only errors that match the predicate.
|
||||
// Thread-safe; preserves original configuration including limit, formatter, and sampling.
|
||||
func (m *MultiError) Filter(fn func(error) bool) *MultiError {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
var opts []MultiErrorOption
|
||||
opts = append(opts, WithLimit(m.limit))
|
||||
if m.formatter != nil {
|
||||
opts = append(opts, WithFormatter(m.formatter))
|
||||
}
|
||||
if m.sampling {
|
||||
opts = append(opts, WithSampling(m.sampleRate))
|
||||
}
|
||||
|
||||
filtered := NewMultiError(opts...)
|
||||
for _, err := range m.errors {
|
||||
if fn(err) {
|
||||
filtered.Add(err)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// First returns the first error in the collection, if any.
|
||||
// Thread-safe; returns nil if the collection is empty.
|
||||
func (m *MultiError) First() error {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if len(m.errors) > 0 {
|
||||
return m.errors[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Has reports whether the collection contains any errors.
|
||||
// Thread-safe.
|
||||
func (m *MultiError) Has() bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return len(m.errors) > 0
|
||||
}
|
||||
|
||||
// Last returns the most recently added error in the collection, if any.
|
||||
// Thread-safe; returns nil if the collection is empty.
|
||||
func (m *MultiError) Last() error {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if len(m.errors) > 0 {
|
||||
return m.errors[len(m.errors)-1]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Merge combines another MultiError's errors into this one.
|
||||
// Thread-safe; respects this instance’s limit and sampling settings; no-op if other is nil or empty.
|
||||
func (m *MultiError) Merge(other *MultiError) {
|
||||
if other == nil || !other.Has() {
|
||||
return
|
||||
}
|
||||
|
||||
other.mu.RLock()
|
||||
defer other.mu.RUnlock()
|
||||
|
||||
for _, err := range other.errors {
|
||||
m.Add(err)
|
||||
}
|
||||
}
|
||||
|
||||
// IsNull checks if the MultiError is empty or contains only null errors.
|
||||
// Returns true if empty or all errors are null (via IsNull() or empty message); thread-safe.
|
||||
func (m *MultiError) IsNull() bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
// Fast path for empty MultiError
|
||||
if len(m.errors) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check each error for null status
|
||||
allNull := true
|
||||
for _, err := range m.errors {
|
||||
switch e := err.(type) {
|
||||
case interface{ IsNull() bool }:
|
||||
if !e.IsNull() {
|
||||
allNull = false
|
||||
break
|
||||
}
|
||||
case nil:
|
||||
continue
|
||||
default:
|
||||
if e.Error() != "" {
|
||||
allNull = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return allNull
|
||||
}
|
||||
|
||||
// Single returns nil if the collection is empty, the single error if only one exists,
|
||||
// or the MultiError itself if multiple errors are present.
|
||||
// Thread-safe; useful for unwrapping to a single error when possible.
|
||||
func (m *MultiError) Single() error {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
switch len(m.errors) {
|
||||
case 0:
|
||||
return nil
|
||||
case 1:
|
||||
return m.errors[0]
|
||||
default:
|
||||
return m
|
||||
}
|
||||
}
|
||||
|
||||
// String implements the Stringer interface for a concise string representation.
|
||||
// Thread-safe; delegates to Error() for formatting.
|
||||
func (m *MultiError) String() string {
|
||||
return m.Error()
|
||||
}
|
||||
|
||||
// Unwrap returns a copy of the contained errors for multi-error unwrapping.
|
||||
// Implements the errors.Unwrap interface; thread-safe; returns nil if empty.
|
||||
func (m *MultiError) Unwrap() []error {
|
||||
return m.Errors()
|
||||
}
|
||||
|
||||
// WithFormatter sets a custom error formatting function.
|
||||
// Returns a MultiErrorOption for use with NewMultiError; overrides default formatting.
|
||||
func WithFormatter(f ErrorFormatter) MultiErrorOption {
|
||||
return func(m *MultiError) {
|
||||
m.formatter = f
|
||||
}
|
||||
}
|
||||
|
||||
// WithLimit sets the maximum number of errors to store.
|
||||
// Returns a MultiErrorOption for use with NewMultiError; 0 means unlimited, negative values are ignored.
|
||||
func WithLimit(n int) MultiErrorOption {
|
||||
return func(m *MultiError) {
|
||||
if n < 0 {
|
||||
n = 0 // Ensure non-negative limit
|
||||
}
|
||||
m.limit = n
|
||||
}
|
||||
}
|
||||
|
||||
// WithSampling enables error sampling with a specified rate (1-100).
|
||||
// Returns a MultiErrorOption for use with NewMultiError; caps rate at 100 for validity.
|
||||
func WithSampling(rate uint32) MultiErrorOption {
|
||||
return func(m *MultiError) {
|
||||
if rate > 100 {
|
||||
rate = 100
|
||||
}
|
||||
m.sampling = true
|
||||
m.sampleRate = rate
|
||||
}
|
||||
}
|
||||
|
||||
// WithRand sets a custom random source for sampling, useful for testing.
|
||||
// Returns a MultiErrorOption for use with NewMultiError; defaults to fastRand if nil.
|
||||
func WithRand(r *rand.Rand) MultiErrorOption {
|
||||
return func(m *MultiError) {
|
||||
m.rand = r
|
||||
}
|
||||
}
|
||||
|
||||
// MarshalJSON serializes the MultiError to JSON, including all contained errors and configuration metadata.
|
||||
// Thread-safe; errors are serialized using their MarshalJSON method if available, otherwise as strings.
|
||||
func (m *MultiError) MarshalJSON() ([]byte, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
// Get buffer from pool for efficiency
|
||||
buf := jsonBufferPool.Get().(*bytes.Buffer)
|
||||
defer jsonBufferPool.Put(buf)
|
||||
buf.Reset()
|
||||
|
||||
// Create encoder
|
||||
enc := json.NewEncoder(buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
|
||||
// Define JSON structure
|
||||
type jsonError struct {
|
||||
Error interface{} `json:"error"` // Holds either JSON-marshaled error or string
|
||||
}
|
||||
|
||||
je := struct {
|
||||
Count int `json:"count"` // Number of errors
|
||||
Limit int `json:"limit,omitempty"` // Maximum error limit (omitted if 0)
|
||||
Sampling bool `json:"sampling,omitempty"` // Whether sampling is enabled
|
||||
SampleRate uint32 `json:"sample_rate,omitempty"` // Sampling rate (1-100, omitted if not sampling)
|
||||
Errors []jsonError `json:"errors"` // List of errors
|
||||
}{
|
||||
Count: len(m.errors),
|
||||
Limit: m.limit,
|
||||
Sampling: m.sampling,
|
||||
SampleRate: m.sampleRate,
|
||||
}
|
||||
|
||||
// Serialize each error
|
||||
je.Errors = make([]jsonError, len(m.errors))
|
||||
for i, err := range m.errors {
|
||||
if err == nil {
|
||||
je.Errors[i] = jsonError{Error: nil}
|
||||
continue
|
||||
}
|
||||
// Check if the error implements json.Marshaler
|
||||
if marshaler, ok := err.(json.Marshaler); ok {
|
||||
marshaled, err := marshaler.MarshalJSON()
|
||||
if err != nil {
|
||||
// Fallback to string if marshaling fails
|
||||
je.Errors[i] = jsonError{Error: err.Error()}
|
||||
} else {
|
||||
var raw json.RawMessage = marshaled
|
||||
je.Errors[i] = jsonError{Error: raw}
|
||||
}
|
||||
} else {
|
||||
// Use error string for non-marshaler errors
|
||||
je.Errors[i] = jsonError{Error: err.Error()}
|
||||
}
|
||||
}
|
||||
|
||||
// Encode JSON
|
||||
if err := enc.Encode(je); err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal MultiError: %v", err)
|
||||
}
|
||||
|
||||
// Remove trailing newline
|
||||
result := buf.Bytes()
|
||||
if len(result) > 0 && result[len(result)-1] == '\n' {
|
||||
result = result[:len(result)-1]
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// defaultFormat provides the default formatting for multiple errors.
|
||||
// Returns a semicolon-separated list prefixed with the error count (e.g., "errors(3): err1; err2; err3").
|
||||
func defaultFormat(errs []error) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("errors(%d): ", len(errs)))
|
||||
for i, err := range errs {
|
||||
if i > 0 {
|
||||
sb.WriteString("; ")
|
||||
}
|
||||
sb.WriteString(err.Error())
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// fastRand generates a quick pseudo-random number for sampling.
|
||||
// Uses a simple xorshift algorithm based on the current time; not cryptographically secure.
|
||||
var fastRandState uint32 = 1 // Must be non-zero
|
||||
|
||||
func fastRand() uint32 {
|
||||
for {
|
||||
// Atomically load the current state
|
||||
old := atomic.LoadUint32(&fastRandState)
|
||||
// Xorshift computation
|
||||
x := old
|
||||
x ^= x << 13
|
||||
x ^= x >> 17
|
||||
x ^= x << 5
|
||||
// Attempt to store the new state atomically
|
||||
if atomic.CompareAndSwapUint32(&fastRandState, old, x) {
|
||||
return x
|
||||
}
|
||||
// Otherwise retry
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
// pool.go
|
||||
package errors
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// ErrorPool is a high-performance, thread-safe pool for reusing *Error instances.
|
||||
// Reduces allocation overhead by recycling errors; tracks hit/miss statistics.
|
||||
type ErrorPool struct {
|
||||
pool sync.Pool // Underlying pool for storing *Error instances
|
||||
poolStats struct { // Embedded struct for pool usage statistics
|
||||
hits atomic.Int64 // Number of times an error was reused from the pool
|
||||
misses atomic.Int64 // Number of times a new error was created due to pool miss
|
||||
}
|
||||
}
|
||||
|
||||
// NewErrorPool creates a new ErrorPool instance.
|
||||
// Initializes the pool with a New function that returns a fresh *Error with default smallContext.
|
||||
func NewErrorPool() *ErrorPool {
|
||||
return &ErrorPool{
|
||||
pool: sync.Pool{
|
||||
New: func() interface{} {
|
||||
return &Error{
|
||||
smallContext: [contextSize]contextItem{},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Get retrieves an *Error from the pool or creates a new one if pooling is disabled or pool is empty.
|
||||
// Resets are handled by Put; thread-safe; updates hit/miss stats when pooling is enabled.
|
||||
func (ep *ErrorPool) Get() *Error {
|
||||
if currentConfig.disablePooling {
|
||||
return &Error{
|
||||
smallContext: [contextSize]contextItem{},
|
||||
}
|
||||
}
|
||||
|
||||
e := ep.pool.Get().(*Error)
|
||||
if e == nil { // Pool returned nil (unlikely due to New func, but handled for safety)
|
||||
ep.poolStats.misses.Add(1)
|
||||
return &Error{
|
||||
smallContext: [contextSize]contextItem{},
|
||||
}
|
||||
}
|
||||
ep.poolStats.hits.Add(1)
|
||||
return e
|
||||
}
|
||||
|
||||
// Put returns an *Error to the pool after resetting it.
|
||||
// Ignores nil errors or if pooling is disabled; preserves stack capacity; thread-safe.
|
||||
func (ep *ErrorPool) Put(e *Error) {
|
||||
if e == nil || currentConfig.disablePooling {
|
||||
return
|
||||
}
|
||||
|
||||
// Reset the error to a clean state, preserving capacity
|
||||
e.Reset()
|
||||
|
||||
// Reset stack length while keeping capacity for reuse
|
||||
if e.stack != nil {
|
||||
e.stack = e.stack[:0]
|
||||
}
|
||||
|
||||
ep.pool.Put(e)
|
||||
}
|
||||
|
||||
// Stats returns the current pool statistics as hits and misses.
|
||||
// Thread-safe; uses atomic loads to ensure accurate counts.
|
||||
func (ep *ErrorPool) Stats() (hits, misses int64) {
|
||||
return ep.poolStats.hits.Load(), ep.poolStats.misses.Load()
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
//go:build go1.24
|
||||
// +build go1.24
|
||||
|
||||
package errors
|
||||
|
||||
import "runtime"
|
||||
|
||||
// setupCleanup configures a cleanup function for an *Error to auto-return it to the pool.
|
||||
// Only active for Go 1.24+; uses runtime.AddCleanup when autoFree is set and pooling is enabled.
|
||||
func (ep *ErrorPool) setupCleanup(e *Error) {
|
||||
if currentConfig.autoFree {
|
||||
runtime.AddCleanup(e, func(_ *struct{}) {
|
||||
if !currentConfig.disablePooling {
|
||||
ep.Put(e) // Return to pool when cleaned up
|
||||
}
|
||||
}, nil) // No additional context needed
|
||||
}
|
||||
}
|
||||
|
||||
// clearCleanup is a no-op for Go 1.24 and above.
|
||||
// Cleanup is managed by runtime.AddCleanup; no explicit removal is required.
|
||||
func (ep *ErrorPool) clearCleanup(e *Error) {
|
||||
// No-op for Go 1.24+
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
//go:build !go1.24
|
||||
// +build !go1.24
|
||||
|
||||
package errors
|
||||
|
||||
import "runtime"
|
||||
|
||||
// setupCleanup configures a finalizer for an *Error to auto-return it to the pool.
|
||||
// Only active for Go versions < 1.24; enables automatic cleanup when autoFree is set and pooling is enabled.
|
||||
func (ep *ErrorPool) setupCleanup(e *Error) {
|
||||
if currentConfig.autoFree {
|
||||
runtime.SetFinalizer(e, func(e *Error) {
|
||||
if !currentConfig.disablePooling {
|
||||
ep.Put(e) // Return to pool when garbage collected
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// clearCleanup removes any finalizer set on an *Error.
|
||||
// Only active for Go versions < 1.24; ensures no cleanup action occurs on garbage collection.
|
||||
func (ep *ErrorPool) clearCleanup(e *Error) {
|
||||
runtime.SetFinalizer(e, nil) // Disable finalizer
|
||||
}
|
||||
+368
@@ -0,0 +1,368 @@
|
||||
// Package errors provides utilities for error handling, including a flexible retry mechanism.
|
||||
package errors
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/rand"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BackoffStrategy defines the interface for calculating retry delays.
|
||||
type BackoffStrategy interface {
|
||||
// Backoff returns the delay for a given attempt based on the base delay.
|
||||
Backoff(attempt int, baseDelay time.Duration) time.Duration
|
||||
}
|
||||
|
||||
// ConstantBackoff provides a fixed delay for each retry attempt.
|
||||
type ConstantBackoff struct{}
|
||||
|
||||
// Backoff returns the base delay regardless of the attempt number.
|
||||
// Implements BackoffStrategy with a constant delay.
|
||||
func (c ConstantBackoff) Backoff(_ int, baseDelay time.Duration) time.Duration {
|
||||
return baseDelay
|
||||
}
|
||||
|
||||
// ExponentialBackoff provides an exponentially increasing delay for retry attempts.
|
||||
type ExponentialBackoff struct{}
|
||||
|
||||
// Backoff returns a delay that doubles with each attempt, starting from the base delay.
|
||||
// Uses bit shifting for efficient exponential growth (e.g., baseDelay * 2^(attempt-1)).
|
||||
func (e ExponentialBackoff) Backoff(attempt int, baseDelay time.Duration) time.Duration {
|
||||
if attempt <= 1 {
|
||||
return baseDelay
|
||||
}
|
||||
return baseDelay * time.Duration(1<<uint(attempt-1))
|
||||
}
|
||||
|
||||
// LinearBackoff provides a linearly increasing delay for retry attempts.
|
||||
type LinearBackoff struct{}
|
||||
|
||||
// Backoff returns a delay that increases linearly with each attempt (e.g., baseDelay * attempt).
|
||||
// Implements BackoffStrategy with linear progression.
|
||||
func (l LinearBackoff) Backoff(attempt int, baseDelay time.Duration) time.Duration {
|
||||
return baseDelay * time.Duration(attempt)
|
||||
}
|
||||
|
||||
// RetryOption configures a Retry instance.
|
||||
// Defines a function type for setting retry parameters.
|
||||
type RetryOption func(*Retry)
|
||||
|
||||
// Retry represents a retryable operation with configurable backoff and retry logic.
|
||||
// Supports multiple attempts, delay strategies, jitter, and context-aware cancellation.
|
||||
type Retry struct {
|
||||
maxAttempts int // Maximum number of attempts (including initial try)
|
||||
delay time.Duration // Base delay for backoff calculations
|
||||
maxDelay time.Duration // Maximum delay cap to prevent excessive waits
|
||||
retryIf func(error) bool // Condition to determine if retry should occur
|
||||
onRetry func(int, error) // Callback executed after each failed attempt
|
||||
backoff BackoffStrategy // Strategy for calculating retry delays
|
||||
jitter bool // Whether to add random jitter to delays
|
||||
ctx context.Context // Context for cancellation and deadlines
|
||||
}
|
||||
|
||||
// NewRetry creates a new Retry instance with the given options.
|
||||
// Defaults: 3 attempts, 100ms base delay, 10s max delay, exponential backoff with jitter,
|
||||
// and retrying on IsRetryable errors; ensures retryIf is never nil.
|
||||
func NewRetry(options ...RetryOption) *Retry {
|
||||
r := &Retry{
|
||||
maxAttempts: 3,
|
||||
delay: 100 * time.Millisecond,
|
||||
maxDelay: 10 * time.Second,
|
||||
retryIf: func(err error) bool { return IsRetryable(err) },
|
||||
onRetry: nil,
|
||||
backoff: ExponentialBackoff{},
|
||||
jitter: true,
|
||||
ctx: context.Background(),
|
||||
}
|
||||
for _, opt := range options {
|
||||
opt(r)
|
||||
}
|
||||
// Ensure retryIf is never nil, falling back to IsRetryable
|
||||
if r.retryIf == nil {
|
||||
r.retryIf = func(err error) bool { return IsRetryable(err) }
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// addJitter adds ±25% jitter to avoid thundering herd problems.
|
||||
// Returns a duration adjusted by a random value between -25% and +25% of the input; not thread-safe.
|
||||
func addJitter(d time.Duration) time.Duration {
|
||||
jitter := time.Duration(rand.Int63n(int64(d/2))) - (d / 4)
|
||||
return d + jitter
|
||||
}
|
||||
|
||||
// Attempts returns the configured maximum number of retry attempts.
|
||||
// Includes the initial attempt in the count.
|
||||
func (r *Retry) Attempts() int {
|
||||
return r.maxAttempts
|
||||
}
|
||||
|
||||
// Execute runs the provided function with the configured retry logic.
|
||||
// Returns nil on success or the last error if all attempts fail; respects context cancellation.
|
||||
func (r *Retry) Execute(fn func() error) error {
|
||||
var lastErr error
|
||||
|
||||
for attempt := 1; attempt <= r.maxAttempts; attempt++ {
|
||||
// Check context before each attempt
|
||||
select {
|
||||
case <-r.ctx.Done():
|
||||
return r.ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
err := fn()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
|
||||
// Check if we should retry
|
||||
if r.retryIf != nil && !r.retryIf(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
if r.onRetry != nil {
|
||||
r.onRetry(attempt, err)
|
||||
}
|
||||
|
||||
// Don't delay after last attempt
|
||||
if attempt == r.maxAttempts {
|
||||
break
|
||||
}
|
||||
|
||||
// Calculate delay with backoff
|
||||
delay := r.backoff.Backoff(attempt, r.delay)
|
||||
if r.maxDelay > 0 && delay > r.maxDelay {
|
||||
delay = r.maxDelay
|
||||
}
|
||||
if r.jitter {
|
||||
delay = addJitter(delay)
|
||||
}
|
||||
|
||||
// Wait with context
|
||||
select {
|
||||
case <-r.ctx.Done():
|
||||
return r.ctx.Err()
|
||||
case <-time.After(delay):
|
||||
}
|
||||
}
|
||||
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// ExecuteContext runs the provided function with retry logic, respecting context cancellation.
|
||||
// Returns nil on success or the last error if all attempts fail or context is cancelled.
|
||||
func (r *Retry) ExecuteContext(ctx context.Context, fn func() error) error {
|
||||
var lastErr error
|
||||
|
||||
// If the retry instance already has a context, use it. Otherwise, use the provided one.
|
||||
// If both are provided, maybe create a derived context? For now, prioritize the one from WithContext.
|
||||
execCtx := r.ctx
|
||||
if execCtx == context.Background() && ctx != nil { // Use provided ctx if retry ctx is default and provided one isn't nil
|
||||
execCtx = ctx
|
||||
} else if ctx == nil { // Ensure we always have a non-nil context
|
||||
execCtx = context.Background()
|
||||
}
|
||||
// Note: This logic might need refinement depending on how contexts should interact.
|
||||
// A safer approach might be: if r.ctx != background, use it. Else use provided ctx.
|
||||
|
||||
for attempt := 1; attempt <= r.maxAttempts; attempt++ {
|
||||
// Check context before executing the function
|
||||
select {
|
||||
case <-execCtx.Done():
|
||||
return execCtx.Err() // Return context error immediately
|
||||
default:
|
||||
// Context is okay, proceed
|
||||
}
|
||||
|
||||
err := fn()
|
||||
if err == nil {
|
||||
return nil // Success
|
||||
}
|
||||
|
||||
// Check if retry is applicable based on the error
|
||||
if r.retryIf != nil && !r.retryIf(err) {
|
||||
return err // Not retryable, return the error
|
||||
}
|
||||
|
||||
lastErr = err // Store the last encountered error
|
||||
|
||||
// Execute the OnRetry callback if configured
|
||||
if r.onRetry != nil {
|
||||
r.onRetry(attempt, err)
|
||||
}
|
||||
|
||||
// Exit loop if this was the last attempt
|
||||
if attempt == r.maxAttempts {
|
||||
break
|
||||
}
|
||||
|
||||
// --- Calculate and apply delay ---
|
||||
currentDelay := r.backoff.Backoff(attempt, r.delay)
|
||||
if r.maxDelay > 0 && currentDelay > r.maxDelay { // Check maxDelay > 0 before capping
|
||||
currentDelay = r.maxDelay
|
||||
}
|
||||
if r.jitter {
|
||||
currentDelay = addJitter(currentDelay)
|
||||
}
|
||||
if currentDelay < 0 { // Ensure delay isn't negative after jitter
|
||||
currentDelay = 0
|
||||
}
|
||||
// --- Wait for the delay or context cancellation ---
|
||||
select {
|
||||
case <-execCtx.Done():
|
||||
// If context is cancelled during the wait, return the context error
|
||||
// Often more informative than returning the last application error.
|
||||
return execCtx.Err()
|
||||
case <-time.After(currentDelay):
|
||||
// Wait finished, continue to the next attempt
|
||||
}
|
||||
}
|
||||
|
||||
// All attempts failed, return the last error encountered
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// Transform creates a new Retry instance with modified configuration.
|
||||
// Copies all settings from the original Retry and applies the given options.
|
||||
func (r *Retry) Transform(opts ...RetryOption) *Retry {
|
||||
newRetry := &Retry{
|
||||
maxAttempts: r.maxAttempts,
|
||||
delay: r.delay,
|
||||
maxDelay: r.maxDelay,
|
||||
retryIf: r.retryIf,
|
||||
onRetry: r.onRetry,
|
||||
backoff: r.backoff,
|
||||
jitter: r.jitter,
|
||||
ctx: r.ctx,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(newRetry)
|
||||
}
|
||||
return newRetry
|
||||
}
|
||||
|
||||
// WithBackoff sets the backoff strategy using the BackoffStrategy interface.
|
||||
// Returns a RetryOption; no-op if strategy is nil, retaining the existing strategy.
|
||||
func WithBackoff(strategy BackoffStrategy) RetryOption {
|
||||
return func(r *Retry) {
|
||||
if strategy != nil {
|
||||
r.backoff = strategy
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithContext sets the context for cancellation and deadlines.
|
||||
// Returns a RetryOption; retains context.Background if ctx is nil.
|
||||
func WithContext(ctx context.Context) RetryOption {
|
||||
return func(r *Retry) {
|
||||
if ctx != nil {
|
||||
r.ctx = ctx
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithDelay sets the initial delay between retries.
|
||||
// Returns a RetryOption; ensures non-negative delay by setting negatives to 0.
|
||||
func WithDelay(delay time.Duration) RetryOption {
|
||||
return func(r *Retry) {
|
||||
if delay < 0 {
|
||||
delay = 0
|
||||
}
|
||||
r.delay = delay
|
||||
}
|
||||
}
|
||||
|
||||
// WithJitter enables or disables jitter in the backoff delay.
|
||||
// Returns a RetryOption; toggles random delay variation.
|
||||
func WithJitter(jitter bool) RetryOption {
|
||||
return func(r *Retry) {
|
||||
r.jitter = jitter
|
||||
}
|
||||
}
|
||||
|
||||
// WithMaxAttempts sets the maximum number of retry attempts.
|
||||
// Returns a RetryOption; ensures at least 1 attempt by adjusting lower values.
|
||||
func WithMaxAttempts(maxAttempts int) RetryOption {
|
||||
return func(r *Retry) {
|
||||
if maxAttempts < 1 {
|
||||
maxAttempts = 1
|
||||
}
|
||||
r.maxAttempts = maxAttempts
|
||||
}
|
||||
}
|
||||
|
||||
// WithMaxDelay sets the maximum delay between retries.
|
||||
// Returns a RetryOption; ensures non-negative delay by setting negatives to 0.
|
||||
func WithMaxDelay(maxDelay time.Duration) RetryOption {
|
||||
return func(r *Retry) {
|
||||
if maxDelay < 0 {
|
||||
maxDelay = 0
|
||||
}
|
||||
r.maxDelay = maxDelay
|
||||
}
|
||||
}
|
||||
|
||||
// WithOnRetry sets a callback to execute after each failed attempt.
|
||||
// Returns a RetryOption; callback receives attempt number and error.
|
||||
func WithOnRetry(onRetry func(attempt int, err error)) RetryOption {
|
||||
return func(r *Retry) {
|
||||
r.onRetry = onRetry
|
||||
}
|
||||
}
|
||||
|
||||
// WithRetryIf sets the condition under which to retry.
|
||||
// Returns a RetryOption; retains IsRetryable default if retryIf is nil.
|
||||
func WithRetryIf(retryIf func(error) bool) RetryOption {
|
||||
return func(r *Retry) {
|
||||
if retryIf != nil {
|
||||
r.retryIf = retryIf
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ExecuteReply runs the provided function with retry logic and returns its result.
|
||||
// Returns the result and nil on success, or zero value and last error on failure; generic type T.
|
||||
func ExecuteReply[T any](r *Retry, fn func() (T, error)) (T, error) {
|
||||
var lastErr error
|
||||
var zero T
|
||||
|
||||
for attempt := 1; attempt <= r.maxAttempts; attempt++ {
|
||||
result, err := fn()
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Check if retry is applicable; return immediately if not retryable
|
||||
if r.retryIf != nil && !r.retryIf(err) {
|
||||
return zero, err
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
if r.onRetry != nil {
|
||||
r.onRetry(attempt, err)
|
||||
}
|
||||
|
||||
if attempt == r.maxAttempts {
|
||||
break
|
||||
}
|
||||
|
||||
// Calculate delay with backoff, cap at maxDelay, and apply jitter if enabled
|
||||
currentDelay := r.backoff.Backoff(attempt, r.delay)
|
||||
if currentDelay > r.maxDelay {
|
||||
currentDelay = r.maxDelay
|
||||
}
|
||||
if r.jitter {
|
||||
currentDelay = addJitter(currentDelay)
|
||||
}
|
||||
|
||||
// Wait with respect to context cancellation or timeout
|
||||
select {
|
||||
case <-r.ctx.Done():
|
||||
return zero, r.ctx.Err()
|
||||
case <-time.After(currentDelay):
|
||||
}
|
||||
}
|
||||
return zero, lastErr
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
// Package errors provides utility functions for error handling, including stack
|
||||
// trace capture and function name extraction.
|
||||
package errors
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// captureStack captures a stack trace with the configured depth.
|
||||
// Skip=0 captures the current call site; skips captureStack and its caller (+2 frames); thread-safe via stackPool.
|
||||
func captureStack(skip int) []uintptr {
|
||||
buf := stackPool.Get().([]uintptr)
|
||||
buf = buf[:cap(buf)]
|
||||
|
||||
// +2 to skip captureStack and the immediate caller
|
||||
n := runtime.Callers(skip+2, buf)
|
||||
if n == 0 {
|
||||
stackPool.Put(buf)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create a new slice to return, avoiding direct use of pooled memory
|
||||
stack := make([]uintptr, n)
|
||||
copy(stack, buf[:n])
|
||||
stackPool.Put(buf)
|
||||
|
||||
return stack
|
||||
}
|
||||
|
||||
// min returns the smaller of two integers.
|
||||
// Simple helper for limiting stack trace size or other comparisons.
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// clearMap removes all entries from a map.
|
||||
// Helper function to reset map contents without reallocating.
|
||||
func clearMap(m map[string]interface{}) {
|
||||
for k := range m {
|
||||
delete(m, k)
|
||||
}
|
||||
}
|
||||
|
||||
// sqlNull detects if a value represents a SQL NULL type.
|
||||
// Returns true for nil or invalid sql.Null* types (e.g., NullString, NullInt64); false otherwise.
|
||||
func sqlNull(v interface{}) bool {
|
||||
if v == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
switch val := v.(type) {
|
||||
case sql.NullString:
|
||||
return !val.Valid
|
||||
case sql.NullTime:
|
||||
return !val.Valid
|
||||
case sql.NullInt64:
|
||||
return !val.Valid
|
||||
case sql.NullBool:
|
||||
return !val.Valid
|
||||
case sql.NullFloat64:
|
||||
return !val.Valid
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// getFuncName extracts the function name from an interface, typically a function or method.
|
||||
// Returns "unknown" if the input is nil or invalid; trims leading dots from runtime name.
|
||||
func getFuncName(fn interface{}) string {
|
||||
if fn == nil {
|
||||
return "unknown"
|
||||
}
|
||||
fullName := runtime.FuncForPC(reflect.ValueOf(fn).Pointer()).Name()
|
||||
return strings.TrimPrefix(fullName, ".")
|
||||
}
|
||||
|
||||
// isInternalFrame determines if a stack frame is considered "internal".
|
||||
// Returns true for frames from runtime, reflect, or this package’s subdirectories if FilterInternal is true.
|
||||
func isInternalFrame(frame runtime.Frame) bool {
|
||||
if strings.HasPrefix(frame.Function, "runtime.") || strings.HasPrefix(frame.Function, "reflect.") {
|
||||
return true
|
||||
}
|
||||
|
||||
suffixes := []string{
|
||||
"errors",
|
||||
"utils",
|
||||
"helper",
|
||||
"retry",
|
||||
"multi",
|
||||
}
|
||||
|
||||
file := frame.File
|
||||
for _, v := range suffixes {
|
||||
if strings.Contains(file, fmt.Sprintf("github.com/olekukonko/errors/%s", v)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// FormatError returns a formatted string representation of an error.
|
||||
// Includes message, name, context, stack trace, and cause for *Error types; just message for others; "<nil>" if nil.
|
||||
func FormatError(err error) string {
|
||||
if err == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
var sb strings.Builder
|
||||
if e, ok := err.(*Error); ok {
|
||||
sb.WriteString(fmt.Sprintf("Error: %s\n", e.Error()))
|
||||
if e.name != "" {
|
||||
sb.WriteString(fmt.Sprintf("Name: %s\n", e.name))
|
||||
}
|
||||
if ctx := e.Context(); len(ctx) > 0 {
|
||||
sb.WriteString("Context:\n")
|
||||
for k, v := range ctx {
|
||||
sb.WriteString(fmt.Sprintf("\t%s: %v\n", k, v))
|
||||
}
|
||||
}
|
||||
if stack := e.Stack(); len(stack) > 0 {
|
||||
sb.WriteString("Stack Trace:\n")
|
||||
for _, frame := range stack {
|
||||
sb.WriteString(fmt.Sprintf("\t%s\n", frame))
|
||||
}
|
||||
}
|
||||
if e.cause != nil {
|
||||
sb.WriteString(fmt.Sprintf("Caused by: %s\n", FormatError(e.cause)))
|
||||
}
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("Error: %s\n", err.Error()))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// Caller returns the file, line, and function name of the caller at the specified skip level.
|
||||
// Skip=0 returns the caller of this function, 1 returns its caller, etc.; returns "unknown" if no caller found.
|
||||
func Caller(skip int) (file string, line int, function string) {
|
||||
configMu.RLock()
|
||||
defer configMu.RUnlock()
|
||||
var pcs [1]uintptr
|
||||
n := runtime.Callers(skip+2, pcs[:]) // +2 skips Caller and its immediate caller
|
||||
if n == 0 {
|
||||
return "", 0, "unknown"
|
||||
}
|
||||
frame, _ := runtime.CallersFrames(pcs[:n]).Next()
|
||||
return frame.File, frame.Line, frame.Function
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.idea
|
||||
lab
|
||||
tmp
|
||||
#_*
|
||||
_test/
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
|
||||
version: 2
|
||||
|
||||
project_name: ll
|
||||
|
||||
# For a library repo, publish source archives instead of binaries.
|
||||
source:
|
||||
enabled: true
|
||||
name_template: "{{ .ProjectName }}_{{ .Version }}"
|
||||
|
||||
# Optional: include/exclude files in the source archive (defaults are usually fine)
|
||||
# files:
|
||||
# - README.md
|
||||
# - LICENSE
|
||||
# - go.mod
|
||||
# - go.sum
|
||||
# - "**/*.go"
|
||||
|
||||
# No binaries to build.
|
||||
builds: []
|
||||
|
||||
## Other Information
|
||||
|
||||
checksum:
|
||||
name_template: "checksums.txt"
|
||||
|
||||
snapshot:
|
||||
version_template: "{{ .Tag }}-next"
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- "^docs:"
|
||||
- "^test:"
|
||||
- "^chore:"
|
||||
- "^ci:"
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Oleku Konko
|
||||
|
||||
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:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
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.
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
# Git remote for pushing tags
|
||||
REMOTE ?= origin
|
||||
|
||||
# Version for release tagging (required for tag/release targets)
|
||||
RELEASE_VERSION ?=
|
||||
|
||||
# Convenience
|
||||
GO ?= go
|
||||
GOLANGCI ?= golangci-lint
|
||||
GORELEASER?= goreleaser
|
||||
|
||||
.PHONY: help \
|
||||
test race bench fmt tidy lint check \
|
||||
ensure-clean ensure-release-version tag tag-delete \
|
||||
release release-dry
|
||||
|
||||
help:
|
||||
@echo "Targets:"
|
||||
@echo " fmt - gofmt + go fmt"
|
||||
@echo " tidy - go mod tidy"
|
||||
@echo " test - go test ./..."
|
||||
@echo " race - go test -race ./..."
|
||||
@echo " bench - go test -bench=. ./..."
|
||||
@echo " lint - golangci-lint run ./... (if installed)"
|
||||
@echo " check - fmt + tidy + test + race"
|
||||
@echo ""
|
||||
@echo "Release targets:"
|
||||
@echo " tag - Create annotated tag RELEASE_VERSION and push"
|
||||
@echo " tag-delete - Delete tag RELEASE_VERSION locally + remote"
|
||||
@echo " release - tag + goreleaser release --clean (if you use goreleaser)"
|
||||
@echo " release-dry - tag + goreleaser release --clean --skip=publish"
|
||||
@echo ""
|
||||
@echo "Usage:"
|
||||
@echo " make check"
|
||||
@echo " make tag RELEASE_VERSION=v0.1.2"
|
||||
@echo " make release RELEASE_VERSION=v0.1.2"
|
||||
|
||||
fmt:
|
||||
@echo "Formatting..."
|
||||
gofmt -w -s .
|
||||
$(GO) fmt ./...
|
||||
|
||||
tidy:
|
||||
@echo "Tidying..."
|
||||
$(GO) mod tidy
|
||||
|
||||
test:
|
||||
@echo "Testing..."
|
||||
$(GO) test ./... -count=1
|
||||
|
||||
race:
|
||||
@echo "Race testing..."
|
||||
$(GO) test ./... -race -count=1
|
||||
|
||||
bench:
|
||||
@echo "Bench..."
|
||||
$(GO) test ./... -bench=. -run=^$$
|
||||
|
||||
lint:
|
||||
@echo "Linting..."
|
||||
@command -v $(GOLANGCI) >/dev/null 2>&1 || { echo "golangci-lint not found"; exit 1; }
|
||||
$(GOLANGCI) run ./...
|
||||
|
||||
check: fmt tidy test race
|
||||
|
||||
# --------------------------
|
||||
# Release helpers
|
||||
# --------------------------
|
||||
|
||||
ensure-clean:
|
||||
@echo "Checking git working tree..."
|
||||
@git diff --quiet || (echo "Error: tracked changes exist. Commit/stash them."; exit 1)
|
||||
@test -z "$$(git status --porcelain)" || (echo "Error: uncommitted/untracked files:"; git status --porcelain; exit 1)
|
||||
@echo "OK: working tree clean"
|
||||
|
||||
ensure-release-version:
|
||||
@test -n "$(RELEASE_VERSION)" || (echo "Error: set RELEASE_VERSION, e.g. make tag RELEASE_VERSION=v0.1.2"; exit 1)
|
||||
|
||||
tag: ensure-clean ensure-release-version
|
||||
@if git rev-parse "$(RELEASE_VERSION)" >/dev/null 2>&1; then \
|
||||
echo "Error: tag $(RELEASE_VERSION) already exists. Bump version."; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "Tagging $(RELEASE_VERSION) at HEAD $$(git rev-parse --short HEAD)"
|
||||
@git tag -a $(RELEASE_VERSION) -m "$(RELEASE_VERSION)"
|
||||
@git push $(REMOTE) $(RELEASE_VERSION)
|
||||
|
||||
tag-delete: ensure-release-version
|
||||
@echo "Deleting tag $(RELEASE_VERSION) locally + remote..."
|
||||
@git tag -d $(RELEASE_VERSION) 2>/dev/null || true
|
||||
@git push $(REMOTE) :refs/tags/$(RELEASE_VERSION) || true
|
||||
|
||||
release: tag
|
||||
@command -v $(GORELEASER) >/dev/null 2>&1 || { echo "goreleaser not found"; exit 1; }
|
||||
$(GORELEASER) release --clean
|
||||
|
||||
release-dry: tag
|
||||
@command -v $(GORELEASER) >/dev/null 2>&1 || { echo "goreleaser not found"; exit 1; }
|
||||
$(GORELEASER) release --clean --skip=publish
|
||||
+417
@@ -0,0 +1,417 @@
|
||||
# ll - A Modern Structured Logging Library for Go
|
||||
|
||||
`ll` is a high-performance, production-ready logging library for Go, designed to provide **hierarchical namespaces**, **structured logging**, **middleware pipelines**, **conditional logging**, and support for multiple output formats, including text, JSON, colorized logs, syslog, VictoriaLogs, and compatibility with Go's `slog`. It's ideal for applications requiring fine-grained log control, extensibility, and scalability.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Logging Enabled by Default** - Zero configuration to start logging
|
||||
- **Hierarchical Namespaces** - Organize logs with fine-grained control over subsystems (e.g., "app/db")
|
||||
- **Structured Logging** - Add key-value metadata for machine-readable logs
|
||||
- **Middleware Pipeline** - Customize log processing with rate limiting, sampling, and deduplication
|
||||
- **Conditional & Error-Based Logging** - Optimize performance with fluent `If`, `IfErr`, `IfAny`, `IfOne` chains
|
||||
- **Multiple Output Formats** - Text, JSON, colorized ANSI, syslog, VictoriaLogs, and `slog` integration
|
||||
- **Advanced Debugging Utilities** - Source-aware `Dbg()`, hex/ASCII `Dump()`, private field `Inspect()`, and stack traces
|
||||
- **Production Ready** - Buffered batching, log rotation, duplicate suppression, and rate limiting
|
||||
- **Thread-Safe** - Built for high-concurrency with atomic operations, sharded mutexes, and lock-free fast paths
|
||||
- **Performance Optimized** - Zero allocations for disabled logs, sync.Pool buffers, LRU caching for source files
|
||||
|
||||
## Installation
|
||||
|
||||
Install `ll` using Go modules:
|
||||
|
||||
```bash
|
||||
go get github.com/olekukonko/ll
|
||||
```
|
||||
|
||||
Requires Go 1.21 or later.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import "github.com/olekukonko/ll"
|
||||
|
||||
func main() {
|
||||
// Logger is ENABLED by default - no .Enable() needed!
|
||||
logger := ll.New("app")
|
||||
|
||||
// Basic logging - works immediately
|
||||
logger.Info("Server starting") // Output: [app] INFO: Server starting
|
||||
logger.Warn("Memory high") // Output: [app] WARN: Memory high
|
||||
logger.Error("Connection failed") // Output: [app] ERROR: Connection failed
|
||||
|
||||
// Structured fields
|
||||
logger.Fields("user", "alice", "status", 200).Info("Login successful")
|
||||
// Output: [app] INFO: Login successful [user=alice status=200]
|
||||
}
|
||||
```
|
||||
|
||||
**That's it. No `.Enable()`, no handlers to configure—it just works.**
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### 1. Enabled by Default, Configurable When Needed
|
||||
|
||||
Unlike many logging libraries that require explicit enabling, `ll` **logs immediately**. This eliminates boilerplate and reduces the chance of missing logs in production.
|
||||
|
||||
```go
|
||||
// This works out of the box:
|
||||
ll.Info("Service started") // Output: [] INFO: Service started
|
||||
|
||||
// But you still have full control:
|
||||
ll.Disable() // Global shutdown
|
||||
ll.Enable() // Reactivate
|
||||
```
|
||||
|
||||
### 2. Hierarchical Namespaces
|
||||
|
||||
Organize logs hierarchically with precise control over subsystems:
|
||||
|
||||
```go
|
||||
// Create a logger hierarchy
|
||||
root := ll.New("app")
|
||||
db := root.Namespace("database")
|
||||
cache := root.Namespace("cache").Style(lx.NestedPath)
|
||||
|
||||
// Control logging per namespace
|
||||
root.NamespaceEnable("app/database") // Enable database logs
|
||||
root.NamespaceDisable("app/cache") // Disable cache logs
|
||||
|
||||
db.Info("Connected") // Output: [app/database] INFO: Connected
|
||||
cache.Info("Hit") // No output (disabled)
|
||||
```
|
||||
|
||||
### 3. Structured Logging with Ordered Fields
|
||||
|
||||
Fields maintain insertion order and support fluent chaining:
|
||||
|
||||
```go
|
||||
// Fluent key-value pairs
|
||||
logger.
|
||||
Fields("request_id", "req-123").
|
||||
Fields("user", "alice").
|
||||
Fields("duration_ms", 42).
|
||||
Info("Request processed")
|
||||
|
||||
// Map-based fields
|
||||
logger.Field(map[string]interface{}{
|
||||
"method": "POST",
|
||||
"path": "/api/users",
|
||||
}).Debug("API call")
|
||||
|
||||
// Persistent context (included in ALL subsequent logs)
|
||||
logger.AddContext("environment", "production", "version", "1.2.3")
|
||||
logger.Info("Deployed") // Output: ... [environment=production version=1.2.3]
|
||||
```
|
||||
|
||||
### 4. Conditional & Error-Based Logging
|
||||
|
||||
Optimize performance with fluent conditional chains that **completely skip processing** when conditions are false:
|
||||
|
||||
```go
|
||||
// Boolean conditions
|
||||
logger.If(debugMode).Debug("Detailed diagnostics") // No overhead when false
|
||||
logger.If(featureEnabled).Info("Feature used")
|
||||
|
||||
// Error conditions
|
||||
err := db.Query()
|
||||
logger.IfErr(err).Error("Query failed") // Logs only if err != nil
|
||||
|
||||
// Multiple conditions - ANY true
|
||||
logger.IfErrAny(err1, err2, err3).Fatal("System failure")
|
||||
|
||||
// Multiple conditions - ALL true
|
||||
logger.IfErrOne(validateErr, authErr).Error("Both checks failed")
|
||||
|
||||
// Chain conditions
|
||||
logger.
|
||||
If(debugMode).
|
||||
IfErr(queryErr).
|
||||
Fields("query", sql).
|
||||
Debug("Query debug")
|
||||
```
|
||||
|
||||
**Performance**: When conditions are false, the logger returns immediately with zero allocations.
|
||||
|
||||
### 5. Powerful Debugging Toolkit
|
||||
|
||||
`ll` includes advanced debugging utilities not found in standard logging libraries:
|
||||
|
||||
#### Dbg() - Source-Aware Variable Inspection
|
||||
Captures both variable name AND value from your source code:
|
||||
|
||||
```go
|
||||
x := 42
|
||||
user := &User{Name: "Alice"}
|
||||
ll.Dbg(x, user)
|
||||
// Output: [file.go:123] x = 42, *user = &{Name:Alice}
|
||||
```
|
||||
|
||||
#### Dump() - Hex/ASCII Binary Inspection
|
||||
Perfect for protocol debugging and binary data:
|
||||
|
||||
```go
|
||||
ll.Handler(lh.NewColorizedHandler(os.Stdout))
|
||||
ll.Dump([]byte("hello\nworld"))
|
||||
// Output: Colorized hex/ASCII dump with offset markers
|
||||
```
|
||||
|
||||
#### Inspect() - Private Field Reflection
|
||||
Reveals unexported fields, embedded structs, and pointer internals:
|
||||
|
||||
```go
|
||||
type secret struct {
|
||||
password string // unexported!
|
||||
}
|
||||
|
||||
s := secret{password: "hunter2"}
|
||||
ll.Inspect(s)
|
||||
// Output: [file.go:123] INSPECT: {
|
||||
// "(password)": "hunter2" // Note the parentheses
|
||||
// }
|
||||
```
|
||||
|
||||
#### Stack() - Configurable Stack Traces
|
||||
```go
|
||||
ll.StackSize(8192) // Larger buffer for deep stacks
|
||||
ll.Stack("Critical failure")
|
||||
// Output: ERROR: Critical failure [stack=goroutine 1 [running]...]
|
||||
```
|
||||
|
||||
#### Mark() - Execution Flow Tracing
|
||||
```go
|
||||
func process() {
|
||||
ll.Mark() // *MARK*: [file.go:123]
|
||||
ll.Mark("phase1") // *phase1*: [file.go:124]
|
||||
// ... work ...
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Production-Ready Handlers
|
||||
|
||||
```go
|
||||
import (
|
||||
"github.com/olekukonko/ll"
|
||||
"github.com/olekukonko/ll/lh"
|
||||
"github.com/olekukonko/ll/l3rd/syslog"
|
||||
"github.com/olekukonko/ll/l3rd/victoria"
|
||||
)
|
||||
|
||||
// JSON for structured logging
|
||||
logger.Handler(lh.NewJSONHandler(os.Stdout))
|
||||
|
||||
// Colorized for development
|
||||
logger.Handler(lh.NewColorizedHandler(os.Stdout,
|
||||
lh.WithColorTheme("dark"),
|
||||
lh.WithColorIntensity(lh.IntensityVibrant),
|
||||
))
|
||||
|
||||
// Buffered for high throughput (100 entries or 10 seconds)
|
||||
buffered := lh.NewBuffered(
|
||||
lh.NewJSONHandler(os.Stdout),
|
||||
lh.WithBatchSize(100),
|
||||
lh.WithFlushInterval(10 * time.Second),
|
||||
)
|
||||
logger.Handler(buffered)
|
||||
defer buffered.Close() // Ensures flush on exit
|
||||
|
||||
// Syslog integration
|
||||
syslogHandler, _ := syslog.New(
|
||||
syslog.WithTag("myapp"),
|
||||
syslog.WithFacility(syslog.LOG_LOCAL0),
|
||||
)
|
||||
logger.Handler(syslogHandler)
|
||||
|
||||
// VictoriaLogs (cloud-native)
|
||||
victoriaHandler, _ := victoria.New(
|
||||
victoria.WithURL("http://victoria-logs:9428"),
|
||||
victoria.WithAppName("payment-service"),
|
||||
victoria.WithEnvironment("production"),
|
||||
victoria.WithBatching(200, 5*time.Second),
|
||||
)
|
||||
logger.Handler(victoriaHandler)
|
||||
```
|
||||
|
||||
### 7. Middleware Pipeline
|
||||
|
||||
Transform, filter, or reject logs with a middleware pipeline:
|
||||
|
||||
```go
|
||||
// Rate limiting - 10 logs per second maximum
|
||||
rateLimiter := lm.NewRateLimiter(lx.LevelInfo, 10, time.Second)
|
||||
logger.Use(rateLimiter)
|
||||
|
||||
// Sampling - 10% of debug logs
|
||||
sampler := lm.NewSampling(lx.LevelDebug, 0.1)
|
||||
logger.Use(sampler)
|
||||
|
||||
// Deduplication - suppress identical logs for 2 seconds
|
||||
deduper := lh.NewDedup(logger.GetHandler(), 2*time.Second)
|
||||
logger.Handler(deduper)
|
||||
|
||||
// Custom middleware
|
||||
logger.Use(ll.Middle(func(e *lx.Entry) error {
|
||||
if strings.Contains(e.Message, "password") {
|
||||
return fmt.Errorf("sensitive information redacted")
|
||||
}
|
||||
return nil
|
||||
}))
|
||||
```
|
||||
|
||||
### 8. Global Convenience API
|
||||
|
||||
Use package-level functions for quick logging without creating loggers:
|
||||
|
||||
```go
|
||||
import "github.com/olekukonko/ll"
|
||||
|
||||
func main() {
|
||||
ll.Info("Server starting") // Global logger
|
||||
ll.Fields("port", 8080).Info("Listening")
|
||||
|
||||
// Conditional logging at package level
|
||||
ll.If(simulation).Debug("Test mode")
|
||||
ll.IfErr(err).Error("Startup failed")
|
||||
|
||||
// Debug utilities
|
||||
ll.Dbg(config)
|
||||
ll.Dump(requestBody)
|
||||
ll.Inspect(complexStruct)
|
||||
}
|
||||
```
|
||||
|
||||
## Real-World Examples
|
||||
|
||||
### Web Server with Structured Logging
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/olekukonko/ll"
|
||||
"github.com/olekukonko/ll/lh"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Root logger - enabled by default
|
||||
log := ll.New("server")
|
||||
|
||||
// JSON output for production
|
||||
log.Handler(lh.NewJSONHandler(os.Stdout))
|
||||
|
||||
// Request logger with context
|
||||
http.HandleFunc("/api/users", func(w http.ResponseWriter, r *http.Request) {
|
||||
reqLog := log.Namespace("http").Fields(
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"request_id", r.Header.Get("X-Request-ID"),
|
||||
)
|
||||
|
||||
start := time.Now()
|
||||
reqLog.Info("request started")
|
||||
|
||||
// ... handle request ...
|
||||
|
||||
reqLog.Fields(
|
||||
"status", 200,
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
).Info("request completed")
|
||||
})
|
||||
|
||||
log.Info("Server listening on :8080")
|
||||
http.ListenAndServe(":8080", nil)
|
||||
}
|
||||
```
|
||||
|
||||
### Microservice with VictoriaLogs
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/olekukonko/ll"
|
||||
"github.com/olekukonko/ll/l3rd/victoria"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Production setup
|
||||
vlHandler, _ := victoria.New(
|
||||
victoria.WithURL("http://logs.internal:9428"),
|
||||
victoria.WithAppName("payment-api"),
|
||||
victoria.WithEnvironment("production"),
|
||||
victoria.WithVersion("1.2.3"),
|
||||
victoria.WithBatching(500, 2*time.Second),
|
||||
victoria.WithRetry(3),
|
||||
)
|
||||
defer vlHandler.Close()
|
||||
|
||||
logger := ll.New("payment").
|
||||
Handler(vlHandler).
|
||||
AddContext("region", "us-east-1")
|
||||
|
||||
logger.Info("Payment service initialized")
|
||||
|
||||
// Conditional error handling
|
||||
if err := processPayment(); err != nil {
|
||||
logger.IfErr(err).
|
||||
Fields("payment_id", paymentID).
|
||||
Error("Payment processing failed")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
`ll` is engineered for high-performance environments:
|
||||
|
||||
| Operation | Time/op | Allocations |
|
||||
|-----------|---------|-------------|
|
||||
| **Disabled log** | **15.9 ns** | **0 allocs** |
|
||||
| Simple text log | 176 ns | 2 allocs |
|
||||
| With 2 fields | 383 ns | 4 allocs |
|
||||
| JSON output | 1006 ns | 13 allocs |
|
||||
| Namespace lookup (cached) | 550 ns | 6 allocs |
|
||||
| Deduplication | 214 ns | 2 allocs |
|
||||
|
||||
**Key optimizations**:
|
||||
- Zero allocations when logs are skipped (conditional, disabled)
|
||||
- Atomic operations for hot paths
|
||||
- Sync.Pool for buffer reuse
|
||||
- LRU cache for source file lines (Dbg)
|
||||
- Sharded mutexes for deduplication
|
||||
|
||||
## Why Choose `ll`?
|
||||
|
||||
| Feature | `ll` | `slog` | `zap` | `logrus` |
|
||||
|---------|------|--------|-------|----------|
|
||||
| **Enabled by default** | ✅ | ❌ | ❌ | ❌ |
|
||||
| Hierarchical namespaces | ✅ | ❌ | ❌ | ❌ |
|
||||
| Conditional logging | ✅ | ❌ | ❌ | ❌ |
|
||||
| Error-based conditions | ✅ | ❌ | ❌ | ❌ |
|
||||
| Source-aware Dbg() | ✅ | ❌ | ❌ | ❌ |
|
||||
| Private field inspection | ✅ | ❌ | ❌ | ❌ |
|
||||
| Hex/ASCII Dump() | ✅ | ❌ | ❌ | ❌ |
|
||||
| Middleware pipeline | ✅ | ❌ | ✅ (limited) | ❌ |
|
||||
| Deduplication | ✅ | ❌ | ❌ | ❌ |
|
||||
| Rate limiting | ✅ | ❌ | ❌ | ❌ |
|
||||
| VictoriaLogs support | ✅ | ❌ | ❌ | ❌ |
|
||||
| Syslog support | ✅ | ❌ | ❌ | ✅ |
|
||||
| Zero-allocs disabled logs | ✅ | ❌ | ❌ | ❌ |
|
||||
| Thread-safe | ✅ | ✅ | ✅ | ✅ |
|
||||
|
||||
## Documentation
|
||||
|
||||
- [GoDoc](https://pkg.go.dev/github.com/olekukonko/ll) - Full API documentation
|
||||
- [Examples](_example/) - Runable example code
|
||||
- [Benchmarks](tests/ll_bench_test.go) - Performance benchmarks
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE) for details.
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
recursive = true
|
||||
output_file = "all.txt"
|
||||
extensions = [".go"]
|
||||
exclude_dirs = ["_examples", "_lab", "_tmp", "pkg", "lab","bin","dist","assets","oppor"]
|
||||
exclude_files = [""]
|
||||
use_gitignore = true
|
||||
detailed = true
|
||||
+427
@@ -0,0 +1,427 @@
|
||||
package ll
|
||||
|
||||
// Conditional enables conditional logging based on a boolean condition.
|
||||
// It wraps a logger with a condition that determines whether logging operations are executed,
|
||||
// optimizing performance by skipping expensive operations (e.g., field computation, message formatting)
|
||||
// when the condition is false. The struct supports fluent chaining for adding fields and logging.
|
||||
type Conditional struct {
|
||||
logger *Logger // Associated logger instance for logging operations
|
||||
condition bool // Whether logging is allowed (true to log, false to skip)
|
||||
}
|
||||
|
||||
// If creates a conditional logger that logs only if the condition is true.
|
||||
// It returns a Conditional struct that wraps the logger, enabling conditional logging methods.
|
||||
// This method is typically called on a Logger instance to start a conditional chain.
|
||||
// Thread-safe via the underlying logger's mutex.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app").Enable()
|
||||
// logger.If(true).Info("Logged") // Output: [app] INFO: Logged
|
||||
// logger.If(false).Info("Ignored") // No output
|
||||
func (l *Logger) If(condition bool) *Conditional {
|
||||
return &Conditional{logger: l, condition: condition}
|
||||
}
|
||||
|
||||
// IfAny creates a conditional logger that logs only if at least one condition is true.
|
||||
// It evaluates a variadic list of boolean conditions, setting the condition to true if any
|
||||
// is true (logical OR). Returns a new Conditional with the result. Thread-safe via the
|
||||
// underlying logger.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app").Enable()
|
||||
// logger.IfAny(false, true).Info("Logged") // Output: [app] INFO: Logged
|
||||
// logger.IfAny(false, false).Info("Ignored") // No output
|
||||
func (cl *Conditional) IfAny(conditions ...bool) *Conditional {
|
||||
result := false
|
||||
// Check each condition; set result to true if any is true
|
||||
for _, cond := range conditions {
|
||||
if cond {
|
||||
result = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return &Conditional{logger: cl.logger, condition: result}
|
||||
}
|
||||
|
||||
// IfErr creates a conditional logger that logs only if the error is non-nil.
|
||||
// It's designed for the common pattern of checking errors before logging.
|
||||
// Example:
|
||||
//
|
||||
// err := doSomething()
|
||||
// logger.IfErr(err).Error("Operation failed") // Only logs if err != nil
|
||||
func (l *Logger) IfErr(err error) *Conditional {
|
||||
return l.If(err != nil)
|
||||
}
|
||||
|
||||
// IfErrAny creates a conditional logger that logs only if AT LEAST ONE error is non-nil.
|
||||
// It evaluates a variadic list of errors, setting the condition to true if any
|
||||
// is non-nil (logical OR). Useful when any error should trigger logging.
|
||||
// Example:
|
||||
//
|
||||
// err1 := validate(input)
|
||||
// err2 := authorize(user)
|
||||
// logger.IfErrAny(err1, err2).Error("Either check failed") // Logs if EITHER error exists
|
||||
func (l *Logger) IfErrAny(errs ...error) *Conditional {
|
||||
for _, err := range errs {
|
||||
if err != nil {
|
||||
return l.If(true) // Any non-nil error makes it true
|
||||
}
|
||||
}
|
||||
return l.If(false) // False only if all errors are nil
|
||||
}
|
||||
|
||||
// IfErrOne creates a conditional logger that logs only if ALL errors are non-nil.
|
||||
// It evaluates a variadic list of errors, setting the condition to true only if
|
||||
// all are non-nil (logical AND). Useful when you need all errors to be present.
|
||||
// Example:
|
||||
//
|
||||
// err1 := validate(input)
|
||||
// err2 := authorize(user)
|
||||
// logger.IfErrOne(err1, err2).Error("Both checks failed") // Logs only if BOTH errors exist
|
||||
func (l *Logger) IfErrOne(errs ...error) *Conditional {
|
||||
for _, err := range errs {
|
||||
if err == nil {
|
||||
return l.If(false) // Any nil error makes it false
|
||||
}
|
||||
}
|
||||
return l.If(len(errs) > 0) // True only if we have at least one error and all are non-nil
|
||||
}
|
||||
|
||||
// IfErr creates a conditional logger that logs only if the error is non-nil.
|
||||
// Returns a new Conditional with the error check result.
|
||||
// Example:
|
||||
//
|
||||
// err := doSomething()
|
||||
// logger.If(true).IfErr(err).Error("Failed") // Only logs if condition true AND err != nil
|
||||
func (cl *Conditional) IfErr(err error) *Conditional {
|
||||
return cl.IfOne(err != nil)
|
||||
}
|
||||
|
||||
// IfErrAny creates a conditional logger that logs only if AT LEAST ONE error is non-nil.
|
||||
// Returns a new Conditional with the logical OR result of error checks.
|
||||
// Example:
|
||||
//
|
||||
// err1 := validate(input)
|
||||
// err2 := authorize(user)
|
||||
// logger.If(true).IfErrAny(err1, err2).Error("Either failed") // Logs if condition true AND either error exists
|
||||
func (cl *Conditional) IfErrAny(errs ...error) *Conditional {
|
||||
for _, err := range errs {
|
||||
if err != nil {
|
||||
return &Conditional{logger: cl.logger, condition: cl.condition && true}
|
||||
}
|
||||
}
|
||||
return &Conditional{logger: cl.logger, condition: false}
|
||||
}
|
||||
|
||||
// IfErrOne creates a conditional logger that logs only if ALL errors are non-nil.
|
||||
// Returns a new Conditional with the logical AND result of error checks.
|
||||
// Example:
|
||||
//
|
||||
// err1 := validate(input)
|
||||
// err2 := authorize(user)
|
||||
// logger.If(true).IfErrOne(err1, err2).Error("Both failed") // Logs if condition true AND both errors exist
|
||||
func (cl *Conditional) IfErrOne(errs ...error) *Conditional {
|
||||
for _, err := range errs {
|
||||
if err == nil {
|
||||
return &Conditional{logger: cl.logger, condition: false}
|
||||
}
|
||||
}
|
||||
return &Conditional{logger: cl.logger, condition: cl.condition && len(errs) > 0}
|
||||
}
|
||||
|
||||
// IfOne creates a conditional logger that logs only if all conditions are true.
|
||||
// It evaluates a variadic list of boolean conditions, setting the condition to true only if
|
||||
// all are true (logical AND). Returns a new Conditional with the result. Thread-safe via the
|
||||
// underlying logger.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app").Enable()
|
||||
// logger.IfOne(true, true).Info("Logged") // Output: [app] INFO: Logged
|
||||
// logger.IfOne(true, false).Info("Ignored") // No output
|
||||
func (cl *Conditional) IfOne(conditions ...bool) *Conditional {
|
||||
result := true
|
||||
// Check each condition; set result to false if any is false
|
||||
for _, cond := range conditions {
|
||||
if !cond {
|
||||
result = false
|
||||
break
|
||||
}
|
||||
}
|
||||
return &Conditional{logger: cl.logger, condition: result}
|
||||
}
|
||||
|
||||
// Debug logs a message at Debug level with variadic arguments if the condition is true.
|
||||
// It concatenates the arguments with spaces and delegates to the logger's Debug method if the
|
||||
// condition is true. Skips processing if false, optimizing performance. Thread-safe via the
|
||||
// logger's log method.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app").Enable().Level(lx.LevelDebug)
|
||||
// logger.If(true).Debug("Debugging", "mode") // Output: [app] DEBUG: Debugging mode
|
||||
// logger.If(false).Debug("Debugging", "ignored") // No output
|
||||
func (cl *Conditional) Debug(args ...any) {
|
||||
// Skip logging if condition is false
|
||||
if !cl.condition {
|
||||
return
|
||||
}
|
||||
// Delegate to logger's Debug method
|
||||
cl.logger.Debug(args...)
|
||||
}
|
||||
|
||||
// Debugf logs a message at Debug level with a format string if the condition is true.
|
||||
// It formats the message and delegates to the logger's Debugf method if the condition is true.
|
||||
// Skips processing if false. Thread-safe via the logger's log method.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app").Enable().Level(lx.LevelDebug)
|
||||
// logger.If(true).Debugf("Debug %s", "mode") // Output: [app] DEBUG: Debug mode
|
||||
// logger.If(false).Debugf("Debug %s", "ignored") // No output
|
||||
func (cl *Conditional) Debugf(format string, args ...any) {
|
||||
// Skip logging if condition is false
|
||||
if !cl.condition {
|
||||
return
|
||||
}
|
||||
// Delegate to logger's Debugf method
|
||||
cl.logger.Debugf(format, args...)
|
||||
}
|
||||
|
||||
// Error logs a message at Error level with variadic arguments if the condition is true.
|
||||
// It concatenates the arguments with spaces and delegates to the logger's Error method if the
|
||||
// condition is true. Skips processing if false. Thread-safe via the logger's log method.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app").Enable()
|
||||
// logger.If(true).Error("Error", "occurred") // Output: [app] ERROR: Error occurred
|
||||
// logger.If(false).Error("Error", "ignored") // No output
|
||||
func (cl *Conditional) Error(args ...any) {
|
||||
// Skip logging if condition is false
|
||||
if !cl.condition {
|
||||
return
|
||||
}
|
||||
// Delegate to logger's Error method
|
||||
cl.logger.Error(args...)
|
||||
}
|
||||
|
||||
// Errorf logs a message at Error level with a format string if the condition is true.
|
||||
// It formats the message and delegates to the logger's Errorf method if the condition is true.
|
||||
// Skips processing if false. Thread-safe via the logger's log method.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app").Enable()
|
||||
// logger.If(true).Errorf("Error %s", "occurred") // Output: [app] ERROR: Error occurred
|
||||
// logger.If(false).Errorf("Error %s", "ignored") // No output
|
||||
func (cl *Conditional) Errorf(format string, args ...any) {
|
||||
// Skip logging if condition is false
|
||||
if !cl.condition {
|
||||
return
|
||||
}
|
||||
// Delegate to logger's Errorf method
|
||||
cl.logger.Errorf(format, args...)
|
||||
}
|
||||
|
||||
// Fatal logs a message at Error level with a stack trace and variadic arguments if the condition is true,
|
||||
// then exits. It concatenates the arguments with spaces and delegates to the logger's Fatal method
|
||||
// if the condition is true, terminating the program with exit code 1. Skips processing if false.
|
||||
// Thread-safe via the logger's log method.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app").Enable()
|
||||
// logger.If(true).Fatal("Fatal", "error") // Output: [app] ERROR: Fatal error [stack=...], then exits
|
||||
// logger.If(false).Fatal("Fatal", "ignored") // No output, no exit
|
||||
func (cl *Conditional) Fatal(args ...any) {
|
||||
// Skip logging if condition is false
|
||||
if !cl.condition {
|
||||
return
|
||||
}
|
||||
// Delegate to logger's Fatal method
|
||||
cl.logger.Fatal(args...)
|
||||
}
|
||||
|
||||
// Fatalf logs a formatted message at Error level with a stack trace if the condition is true, then exits.
|
||||
// It formats the message and delegates to the logger's Fatalf method if the condition is true,
|
||||
// terminating the program with exit code 1. Skips processing if false. Thread-safe via the logger's log method.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app").Enable()
|
||||
// logger.If(true).Fatalf("Fatal %s", "error") // Output: [app] ERROR: Fatal error [stack=...], then exits
|
||||
// logger.If(false).Fatalf("Fatal %s", "ignored") // No output, no exit
|
||||
func (cl *Conditional) Fatalf(format string, args ...any) {
|
||||
// Skip logging if condition is false
|
||||
if !cl.condition {
|
||||
return
|
||||
}
|
||||
// Delegate to logger's Fatalf method
|
||||
cl.logger.Fatalf(format, args...)
|
||||
}
|
||||
|
||||
// Field starts a fluent chain for adding fields from a map, if the condition is true.
|
||||
// It returns a FieldBuilder to attach fields from a map, skipping processing if the condition
|
||||
// is false. Thread-safe via the FieldBuilder's logger.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app").Enable()
|
||||
// logger.If(true).Field(map[string]interface{}{"user": "alice"}).Info("Logged") // Output: [app] INFO: Logged [user=alice]
|
||||
// logger.If(false).Field(map[string]interface{}{"user": "alice"}).Info("Ignored") // No output
|
||||
func (cl *Conditional) Field(fields map[string]interface{}) *FieldBuilder {
|
||||
// Skip field processing if condition is false
|
||||
if !cl.condition {
|
||||
return &FieldBuilder{logger: cl.logger, fields: nil}
|
||||
}
|
||||
// Delegate to logger's Field method
|
||||
return cl.logger.Field(fields)
|
||||
}
|
||||
|
||||
// Fields starts a fluent chain for adding fields using variadic key-value pairs, if the condition is true.
|
||||
// It returns a FieldBuilder to attach fields, skipping field processing if the condition is false
|
||||
// to optimize performance. Thread-safe via the FieldBuilder's logger.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app").Enable()
|
||||
// logger.If(true).Fields("user", "alice").Info("Logged") // Output: [app] INFO: Logged [user=alice]
|
||||
// logger.If(false).Fields("user", "alice").Info("Ignored") // No output, no field processing
|
||||
func (cl *Conditional) Fields(pairs ...any) *FieldBuilder {
|
||||
// Skip field processing if condition is false
|
||||
if !cl.condition {
|
||||
return &FieldBuilder{logger: cl.logger, fields: nil}
|
||||
}
|
||||
// Delegate to logger's Fields method
|
||||
return cl.logger.Fields(pairs...)
|
||||
}
|
||||
|
||||
// Info logs a message at Info level with variadic arguments if the condition is true.
|
||||
// It concatenates the arguments with spaces and delegates to the logger's Info method if the
|
||||
// condition is true. Skips processing if false, optimizing performance. Thread-safe via the
|
||||
// logger's log method.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app").Enable()
|
||||
// logger.If(true).Info("Action", "started") // Output: [app] INFO: Action started
|
||||
// logger.If(false).Info("Action", "ignored") // No output
|
||||
func (cl *Conditional) Info(args ...any) {
|
||||
// Skip logging if condition is false
|
||||
if !cl.condition {
|
||||
return
|
||||
}
|
||||
// Delegate to logger's Info method
|
||||
cl.logger.Info(args...)
|
||||
}
|
||||
|
||||
// Infof logs a message at Info level with a format string if the condition is true.
|
||||
// It formats the message using the provided format string and arguments, delegating to the
|
||||
// logger's Infof method if the condition is true. Skips processing if false, optimizing performance.
|
||||
// Thread-safe via the logger's log method.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app").Enable()
|
||||
// logger.If(true).Infof("Action %s", "started") // Output: [app] INFO: Action started
|
||||
// logger.If(false).Infof("Action %s", "ignored") // No output
|
||||
func (cl *Conditional) Infof(format string, args ...any) {
|
||||
// Skip logging if condition is false
|
||||
if !cl.condition {
|
||||
return
|
||||
}
|
||||
// Delegate to logger's Infof method
|
||||
cl.logger.Infof(format, args...)
|
||||
}
|
||||
|
||||
// Panic logs a message at Error level with a stack trace and variadic arguments if the condition is true,
|
||||
// then panics. It concatenates the arguments with spaces and delegates to the logger's Panic method
|
||||
// if the condition is true, triggering a panic. Skips processing if false. Thread-safe via the logger's log method.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app").Enable()
|
||||
// logger.If(true).Panic("Panic", "error") // Output: [app] ERROR: Panic error [stack=...], then panics
|
||||
// logger.If(false).Panic("Panic", "ignored") // No output, no panic
|
||||
func (cl *Conditional) Panic(args ...any) {
|
||||
// Skip logging if condition is false
|
||||
if !cl.condition {
|
||||
return
|
||||
}
|
||||
// Delegate to logger's Panic method
|
||||
cl.logger.Panic(args...)
|
||||
}
|
||||
|
||||
// Panicf logs a formatted message at Error level with a stack trace if the condition is true, then panics.
|
||||
// It formats the message and delegates to the logger's Panicf method if the condition is true,
|
||||
// triggering a panic. Skips processing if false. Thread-safe via the logger's log method.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app").Enable()
|
||||
// logger.If(true).Panicf("Panic %s", "error") // Output: [app] ERROR: Panic error [stack=...], then panics
|
||||
// logger.If(false).Panicf("Panic %s", "ignored") // No output, no panic
|
||||
func (cl *Conditional) Panicf(format string, args ...any) {
|
||||
// Skip logging if condition is false
|
||||
if !cl.condition {
|
||||
return
|
||||
}
|
||||
// Delegate to logger's Panicf method
|
||||
cl.logger.Panicf(format, args...)
|
||||
}
|
||||
|
||||
// Stack logs a message at Error level with a stack trace and variadic arguments if the condition is true.
|
||||
// It concatenates the arguments with spaces and delegates to the logger's Stack method if the
|
||||
// condition is true. Skips processing if false. Thread-safe via the logger's log method.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app").Enable()
|
||||
// logger.If(true).Stack("Critical", "error") // Output: [app] ERROR: Critical error [stack=...]
|
||||
// logger.If(false).Stack("Critical", "ignored") // No output
|
||||
func (cl *Conditional) Stack(args ...any) {
|
||||
// Skip logging if condition is false
|
||||
if !cl.condition {
|
||||
return
|
||||
}
|
||||
// Delegate to logger's Stack method
|
||||
cl.logger.Stack(args...)
|
||||
}
|
||||
|
||||
// Stackf logs a message at Error level with a stack trace and a format string if the condition is true.
|
||||
// It formats the message and delegates to the logger's Stackf method if the condition is true.
|
||||
// Skips processing if false. Thread-safe via the logger's log method.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app").Enable()
|
||||
// logger.If(true).Stackf("Critical %s", "error") // Output: [app] ERROR: Critical error [stack=...]
|
||||
// logger.If(false).Stackf("Critical %s", "ignored") // No output
|
||||
func (cl *Conditional) Stackf(format string, args ...any) {
|
||||
// Skip logging if condition is false
|
||||
if !cl.condition {
|
||||
return
|
||||
}
|
||||
// Delegate to logger's Stackf method
|
||||
cl.logger.Stackf(format, args...)
|
||||
}
|
||||
|
||||
// Warn logs a message at Warn level with variadic arguments if the condition is true.
|
||||
// It concatenates the arguments with spaces and delegates to the logger's Warn method if the
|
||||
// condition is true. Skips processing if false. Thread-safe via the logger's log method.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app").Enable()
|
||||
// logger.If(true).Warn("Warning", "issued") // Output: [app] WARN: Warning issued
|
||||
// logger.If(false).Warn("Warning", "ignored") // No output
|
||||
func (cl *Conditional) Warn(args ...any) {
|
||||
// Skip logging if condition is false
|
||||
if !cl.condition {
|
||||
return
|
||||
}
|
||||
// Delegate to logger's Warn method
|
||||
cl.logger.Warn(args...)
|
||||
}
|
||||
|
||||
// Warnf logs a message at Warn level with a format string if the condition is true.
|
||||
// It formats the message and delegates to the logger's Warnf method if the condition is true.
|
||||
// Skips processing if false. Thread-safe via the logger's log method.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app").Enable()
|
||||
// logger.If(true).Warnf("Warning %s", "issued") // Output: [app] WARN: Warning issued
|
||||
// logger.If(false).Warnf("Warning %s", "ignored") // No output
|
||||
func (cl *Conditional) Warnf(format string, args ...any) {
|
||||
// Skip logging if condition is false
|
||||
if !cl.condition {
|
||||
return
|
||||
}
|
||||
// Delegate to logger's Warnf method
|
||||
cl.logger.Warnf(format, args...)
|
||||
}
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
package ll
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/olekukonko/ll/lx"
|
||||
)
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Global Cache Instance
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// sourceCache caches up to 128 source files using LRU eviction.
|
||||
var sourceCache = newFileLRU(128)
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// File-Level LRU Cache
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
type fileLRU struct {
|
||||
capacity int
|
||||
mu sync.Mutex
|
||||
list *list.List
|
||||
items map[string]*list.Element
|
||||
}
|
||||
|
||||
type fileItem struct {
|
||||
key string
|
||||
lines []string
|
||||
}
|
||||
|
||||
func newFileLRU(capacity int) *fileLRU {
|
||||
if capacity <= 0 {
|
||||
capacity = 1
|
||||
}
|
||||
return &fileLRU{
|
||||
capacity: capacity,
|
||||
list: list.New(),
|
||||
items: make(map[string]*list.Element, capacity),
|
||||
}
|
||||
}
|
||||
|
||||
// getLine retrieves a specific 1-indexed line from a file.
|
||||
func (c *fileLRU) getLine(file string, line int) (string, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// 1. Cache Hit
|
||||
if elem, ok := c.items[file]; ok {
|
||||
c.list.MoveToFront(elem)
|
||||
item := elem.Value.(*fileItem)
|
||||
if item.lines == nil {
|
||||
return "", false
|
||||
}
|
||||
return nthLine(item.lines, line)
|
||||
}
|
||||
|
||||
// 2. Cache Miss - Read File
|
||||
// Release lock during I/O to avoid blocking other loggers
|
||||
c.mu.Unlock()
|
||||
data, err := os.ReadFile(file)
|
||||
c.mu.Lock()
|
||||
|
||||
// 3. Double-check (another goroutine might have loaded it while unlocked)
|
||||
if elem, ok := c.items[file]; ok {
|
||||
c.list.MoveToFront(elem)
|
||||
item := elem.Value.(*fileItem)
|
||||
if item.lines == nil {
|
||||
return "", false
|
||||
}
|
||||
return nthLine(item.lines, line)
|
||||
}
|
||||
|
||||
var lines []string
|
||||
if err == nil {
|
||||
lines = strings.Split(string(data), "\n")
|
||||
}
|
||||
|
||||
// 4. Store (Positive or Negative Cache)
|
||||
item := &fileItem{
|
||||
key: file,
|
||||
lines: lines,
|
||||
}
|
||||
elem := c.list.PushFront(item)
|
||||
c.items[file] = elem
|
||||
|
||||
// 5. Evict if needed
|
||||
if c.list.Len() > c.capacity {
|
||||
old := c.list.Back()
|
||||
if old != nil {
|
||||
c.list.Remove(old)
|
||||
delete(c.items, old.Value.(*fileItem).key)
|
||||
}
|
||||
}
|
||||
|
||||
if lines == nil {
|
||||
return "", false
|
||||
}
|
||||
return nthLine(lines, line)
|
||||
}
|
||||
|
||||
// nthLine returns the 1-indexed line from slice.
|
||||
func nthLine(lines []string, n int) (string, bool) {
|
||||
if n <= 0 || n > len(lines) {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimSuffix(lines[n-1], "\r"), true
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Logger Debug Implementation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// Dbg logs debug information including source file, line number,
|
||||
// and the best-effort extracted expression.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// x := 42
|
||||
// logger.Dbg("val", x)
|
||||
// Output: [file.go:123] "val" = "val", x = 42
|
||||
func (l *Logger) Dbg(values ...interface{}) {
|
||||
if !l.shouldLog(lx.LevelInfo) {
|
||||
return
|
||||
}
|
||||
l.dbg(2, values...)
|
||||
}
|
||||
|
||||
func (l *Logger) dbg(skip int, values ...interface{}) {
|
||||
file, line, ok := callerFrame(skip)
|
||||
if !ok {
|
||||
// Fallback if we can't get frame
|
||||
var sb strings.Builder
|
||||
sb.WriteString("[?:?] ")
|
||||
for i, v := range values {
|
||||
if i > 0 {
|
||||
sb.WriteString(", ")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%+v", v))
|
||||
}
|
||||
l.log(lx.LevelInfo, lx.ClassText, sb.String(), nil, false)
|
||||
return
|
||||
}
|
||||
|
||||
shortFile := file
|
||||
if idx := strings.LastIndex(file, "/"); idx >= 0 {
|
||||
shortFile = file[idx+1:]
|
||||
}
|
||||
|
||||
srcLine, hit := sourceCache.getLine(file, line)
|
||||
|
||||
var expr string
|
||||
if hit && srcLine != "" {
|
||||
// Attempt to extract the text inside Dbg(...)
|
||||
if a := strings.Index(srcLine, "Dbg("); a >= 0 {
|
||||
rest := srcLine[a+len("Dbg("):]
|
||||
if b := strings.LastIndex(rest, ")"); b >= 0 {
|
||||
expr = strings.TrimSpace(rest[:b])
|
||||
}
|
||||
} else {
|
||||
// Fallback: extract first (...) group if Dbg isn't explicit prefix
|
||||
a := strings.Index(srcLine, "(")
|
||||
b := strings.LastIndex(srcLine, ")")
|
||||
if a >= 0 && b > a {
|
||||
expr = strings.TrimSpace(srcLine[a+1 : b])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Format output
|
||||
var outBuilder strings.Builder
|
||||
outBuilder.WriteString(fmt.Sprintf("[%s:%d] ", shortFile, line))
|
||||
|
||||
// Attempt to split expressions to map 1:1 with values
|
||||
var parts []string
|
||||
if expr != "" {
|
||||
parts = splitExpressions(expr)
|
||||
}
|
||||
|
||||
// If the number of extracted expressions matches the number of values,
|
||||
// print them as "expr = value". Otherwise, fall back to "expr = val1, val2".
|
||||
if len(parts) == len(values) {
|
||||
for i, v := range values {
|
||||
if i > 0 {
|
||||
outBuilder.WriteString(", ")
|
||||
}
|
||||
outBuilder.WriteString(fmt.Sprintf("%s = %+v", parts[i], v))
|
||||
}
|
||||
} else {
|
||||
if expr != "" {
|
||||
outBuilder.WriteString(expr)
|
||||
outBuilder.WriteString(" = ")
|
||||
}
|
||||
for i, v := range values {
|
||||
if i > 0 {
|
||||
outBuilder.WriteString(", ")
|
||||
}
|
||||
outBuilder.WriteString(fmt.Sprintf("%+v", v))
|
||||
}
|
||||
}
|
||||
|
||||
l.log(lx.LevelInfo, lx.ClassDbg, outBuilder.String(), nil, false)
|
||||
}
|
||||
|
||||
// splitExpressions splits a comma-separated string of expressions,
|
||||
// respecting nested parentheses, brackets, braces, and quotes.
|
||||
// Example: "a, fn(b, c), d" -> ["a", "fn(b, c)", "d"]
|
||||
func splitExpressions(s string) []string {
|
||||
var parts []string
|
||||
var current strings.Builder
|
||||
depth := 0 // Tracks nested (), [], {}
|
||||
inQuote := false // Tracks string literals
|
||||
var quoteChar rune
|
||||
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case inQuote:
|
||||
current.WriteRune(r)
|
||||
if r == quoteChar {
|
||||
// We rely on the fact that valid Go source won't have unescaped quotes easily
|
||||
// accessible here without complex parsing, but for simple Dbg calls this suffices.
|
||||
// A robust parser handles `\"`, but simple state toggling covers 99% of debug cases.
|
||||
inQuote = false
|
||||
}
|
||||
case r == '"' || r == '\'':
|
||||
inQuote = true
|
||||
quoteChar = r
|
||||
current.WriteRune(r)
|
||||
case r == '(' || r == '{' || r == '[':
|
||||
depth++
|
||||
current.WriteRune(r)
|
||||
case r == ')' || r == '}' || r == ']':
|
||||
depth--
|
||||
current.WriteRune(r)
|
||||
case r == ',' && depth == 0:
|
||||
// Split point
|
||||
parts = append(parts, strings.TrimSpace(current.String()))
|
||||
current.Reset()
|
||||
default:
|
||||
current.WriteRune(r)
|
||||
}
|
||||
}
|
||||
if current.Len() > 0 {
|
||||
parts = append(parts, strings.TrimSpace(current.String()))
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Caller Resolution
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// callerFrame walks stack frames until it finds the first frame
|
||||
// outside the ll package.
|
||||
func callerFrame(skip int) (file string, line int, ok bool) {
|
||||
// +2 to skip callerFrame + dbg itself.
|
||||
pcs := make([]uintptr, 32)
|
||||
n := runtime.Callers(skip+2, pcs)
|
||||
if n == 0 {
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
frames := runtime.CallersFrames(pcs[:n])
|
||||
for {
|
||||
fr, more := frames.Next()
|
||||
// fr.Function looks like: "github.com/you/mod/ll.(*Logger).Dbg"
|
||||
// We want the first frame that is NOT inside package ll.
|
||||
if fr.Function == "" || !strings.Contains(fr.Function, "/ll.") && !strings.Contains(fr.Function, ".ll.") {
|
||||
return fr.File, fr.Line, true
|
||||
}
|
||||
|
||||
if !more {
|
||||
// Fallback: return the last frame we saw
|
||||
return fr.File, fr.Line, fr.File != ""
|
||||
}
|
||||
}
|
||||
}
|
||||
+388
@@ -0,0 +1,388 @@
|
||||
// field.go
|
||||
package ll
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/olekukonko/cat"
|
||||
"github.com/olekukonko/ll/lx"
|
||||
)
|
||||
|
||||
// FieldBuilder enables fluent addition of fields before logging.
|
||||
// It acts as a builder pattern to attach key-value pairs (fields) to log entries,
|
||||
// supporting structured logging with metadata. The builder allows chaining to add fields
|
||||
// and log messages at various levels (Info, Debug, Warn, Error, etc.) in a single expression.
|
||||
type FieldBuilder struct {
|
||||
logger *Logger // Associated logger instance for logging operations
|
||||
fields lx.Fields // Fields to include in the log entry as ordered key-value pairs
|
||||
}
|
||||
|
||||
// Logger creates a new logger with the builder's fields embedded in its context.
|
||||
// It clones the parent logger and copies the builder's fields into the new logger's context,
|
||||
// enabling persistent field inclusion in subsequent logs. This method supports fluent chaining
|
||||
// after Fields or Field calls.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app").Enable()
|
||||
// newLogger := logger.Fields("user", "alice").Logger()
|
||||
// newLogger.Info("Action") // Output: [app] INFO: Action [user=alice]
|
||||
func (fb *FieldBuilder) Logger() *Logger {
|
||||
// Clone the parent logger to preserve its configuration
|
||||
newLogger := fb.logger.Clone()
|
||||
// Copy builder's fields into the new logger's context
|
||||
newLogger.context = make(lx.Fields, len(fb.fields))
|
||||
copy(newLogger.context, fb.fields)
|
||||
return newLogger
|
||||
}
|
||||
|
||||
// Info logs a message at Info level with the builder's fields.
|
||||
// It concatenates the arguments with spaces and delegates to the logger's log method,
|
||||
// returning early if fields are nil. This method is used for informational messages.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app").Enable()
|
||||
// logger.Fields("user", "alice").Info("Action", "started") // Output: [app] INFO: Action started [user=alice]
|
||||
func (fb *FieldBuilder) Info(args ...any) {
|
||||
// Skip logging if fields are nil
|
||||
if fb.fields == nil {
|
||||
return
|
||||
}
|
||||
// Log at Info level with the builder's fields, no stack trace
|
||||
fb.logger.log(lx.LevelInfo, lx.ClassText, cat.Space(args...), fb.fields, false)
|
||||
}
|
||||
|
||||
// Infof logs a message at Info level with the builder's fields.
|
||||
// It formats the message using the provided format string and arguments, then delegates
|
||||
// to the logger's internal log method. If fields are nil, it returns early to avoid logging.
|
||||
// This method is part of the fluent API, typically called after adding fields.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app").Enable()
|
||||
// logger.Fields("user", "alice").Infof("Action %s", "started") // Output: [app] INFO: Action started [user=alice]
|
||||
func (fb *FieldBuilder) Infof(format string, args ...any) {
|
||||
// Skip logging if fields are nil to prevent invalid log entries
|
||||
if fb.fields == nil {
|
||||
return
|
||||
}
|
||||
// Format the message using the provided arguments
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
// Log at Info level with the builder's fields, no stack trace
|
||||
fb.logger.log(lx.LevelInfo, lx.ClassText, msg, fb.fields, false)
|
||||
}
|
||||
|
||||
// Debug logs a message at Debug level with the builder's fields.
|
||||
// It concatenates the arguments with spaces and delegates to the logger's log method,
|
||||
// returning early if fields are nil. This method is used for debugging information.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app").Enable()
|
||||
// logger.Fields("user", "alice").Debug("Debugging", "mode") // Output: [app] DEBUG: Debugging mode [user=alice]
|
||||
func (fb *FieldBuilder) Debug(args ...any) {
|
||||
// Skip logging if fields are nil
|
||||
if fb.fields == nil {
|
||||
return
|
||||
}
|
||||
// Log at Debug level with the builder's fields, no stack trace
|
||||
fb.logger.log(lx.LevelDebug, lx.ClassText, cat.Space(args...), fb.fields, false)
|
||||
}
|
||||
|
||||
// Debugf logs a message at Debug level with the builder's fields.
|
||||
// It formats the message and delegates to the logger's log method, returning early if
|
||||
// fields are nil. This method is used for debugging information that may be disabled in
|
||||
// production environments.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app").Enable()
|
||||
// logger.Fields("user", "alice").Debugf("Debug %s", "mode") // Output: [app] DEBUG: Debug mode [user=alice]
|
||||
func (fb *FieldBuilder) Debugf(format string, args ...any) {
|
||||
// Skip logging if fields are nil
|
||||
if fb.fields == nil {
|
||||
return
|
||||
}
|
||||
// Format the message
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
// Log at Debug level with the builder's fields, no stack trace
|
||||
fb.logger.log(lx.LevelDebug, lx.ClassText, msg, fb.fields, false)
|
||||
}
|
||||
|
||||
// Warn logs a message at Warn level with the builder's fields.
|
||||
// It concatenates the arguments with spaces and delegates to the logger's log method,
|
||||
// returning early if fields are nil. This method is used for warning conditions.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app").Enable()
|
||||
// logger.Fields("user", "alice").Warn("Warning", "issued") // Output: [app] WARN: Warning issued [user=alice]
|
||||
func (fb *FieldBuilder) Warn(args ...any) {
|
||||
// Skip logging if fields are nil
|
||||
if fb.fields == nil {
|
||||
return
|
||||
}
|
||||
// Log at Warn level with the builder's fields, no stack trace
|
||||
fb.logger.log(lx.LevelWarn, lx.ClassText, cat.Space(args...), fb.fields, false)
|
||||
}
|
||||
|
||||
// Warnf logs a message at Warn level with the builder's fields.
|
||||
// It formats the message and delegates to the logger's log method, returning early if
|
||||
// fields are nil. This method is used for warning conditions that do not halt execution.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app").Enable()
|
||||
// logger.Fields("user", "alice").Warnf("Warning %s", "issued") // Output: [app] WARN: Warning issued [user=alice]
|
||||
func (fb *FieldBuilder) Warnf(format string, args ...any) {
|
||||
// Skip logging if fields are nil
|
||||
if fb.fields == nil {
|
||||
return
|
||||
}
|
||||
// Format the message
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
// Log at Warn level with the builder's fields, no stack trace
|
||||
fb.logger.log(lx.LevelWarn, lx.ClassText, msg, fb.fields, false)
|
||||
}
|
||||
|
||||
// Error logs a message at Error level with the builder's fields.
|
||||
// It concatenates the arguments with spaces and delegates to the logger's log method,
|
||||
// returning early if fields are nil. This method is used for error conditions.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app").Enable()
|
||||
// logger.Fields("user", "alice").Error("Error", "occurred") // Output: [app] ERROR: Error occurred [user=alice]
|
||||
func (fb *FieldBuilder) Error(args ...any) {
|
||||
// Skip logging if fields are nil
|
||||
if fb.fields == nil {
|
||||
return
|
||||
}
|
||||
// Log at Error level with the builder's fields, no stack trace
|
||||
fb.logger.log(lx.LevelError, lx.ClassText, cat.Space(args...), fb.fields, false)
|
||||
}
|
||||
|
||||
// Errorf logs a message at Error level with the builder's fields.
|
||||
// It formats the message and delegates to the logger's log method, returning early if
|
||||
// fields are nil. This method is used for error conditions that may require attention.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app").Enable()
|
||||
// logger.Fields("user", "alice").Errorf("Error %s", "occurred") // Output: [app] ERROR: Error occurred [user=alice]
|
||||
func (fb *FieldBuilder) Errorf(format string, args ...any) {
|
||||
// Skip logging if fields are nil
|
||||
if fb.fields == nil {
|
||||
return
|
||||
}
|
||||
// Format the message
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
// Log at Error level with the builder's fields, no stack trace
|
||||
fb.logger.log(lx.LevelError, lx.ClassText, msg, fb.fields, false)
|
||||
}
|
||||
|
||||
// Stack logs a message at Error level with a stack trace and the builder's fields.
|
||||
// It concatenates the arguments with spaces and delegates to the logger's log method,
|
||||
// returning early if fields are nil. This method is useful for debugging critical errors.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app").Enable()
|
||||
// logger.Fields("user", "alice").Stack("Critical", "error") // Output: [app] ERROR: Critical error [user=alice stack=...]
|
||||
func (fb *FieldBuilder) Stack(args ...any) {
|
||||
// Skip logging if fields are nil
|
||||
if fb.fields == nil {
|
||||
return
|
||||
}
|
||||
// Log at Error level with the builder's fields and a stack trace
|
||||
fb.logger.log(lx.LevelError, lx.ClassText, cat.Space(args...), fb.fields, true)
|
||||
}
|
||||
|
||||
// Stackf logs a message at Error level with a stack trace and the builder's fields.
|
||||
// It formats the message and delegates to the logger's log method, returning early if
|
||||
// fields are nil. This method is useful for debugging critical errors.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app").Enable()
|
||||
// logger.Fields("user", "alice").Stackf("Critical %s", "error") // Output: [app] ERROR: Critical error [user=alice stack=...]
|
||||
func (fb *FieldBuilder) Stackf(format string, args ...any) {
|
||||
// Skip logging if fields are nil
|
||||
if fb.fields == nil {
|
||||
return
|
||||
}
|
||||
// Format the message
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
// Log at Error level with the builder's fields and a stack trace
|
||||
fb.logger.log(lx.LevelError, lx.ClassText, msg, fb.fields, true)
|
||||
}
|
||||
|
||||
// Fatal logs a message at Error level with a stack trace and the builder's fields, then exits.
|
||||
// It constructs the message from variadic arguments, logs it with a stack trace, and terminates
|
||||
// the program with exit code 1. Returns early if fields are nil. This method is used for
|
||||
// unrecoverable errors.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app").Enable()
|
||||
// logger.Fields("user", "alice").Fatal("Fatal", "error") // Output: [app] ERROR: Fatal error [user=alice stack=...], then exits
|
||||
func (fb *FieldBuilder) Fatal(args ...any) {
|
||||
// Skip logging if fields are nil
|
||||
if fb.fields == nil {
|
||||
return
|
||||
}
|
||||
// Build the message by concatenating arguments with spaces
|
||||
var builder strings.Builder
|
||||
for i, arg := range args {
|
||||
if i > 0 {
|
||||
builder.WriteString(lx.Space)
|
||||
}
|
||||
builder.WriteString(fmt.Sprint(arg))
|
||||
}
|
||||
// Log at Error level with the builder's fields and a stack trace
|
||||
fb.logger.log(lx.LevelFatal, lx.ClassText, builder.String(), fb.fields, fb.logger.fatalStack)
|
||||
|
||||
// Exit the program with status code 1
|
||||
if fb.logger.fatalExits {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Fatalf logs a formatted message at Error level with a stack trace and the builder's fields,
|
||||
// then exits. It delegates to Fatal and returns early if fields are nil. This method is used
|
||||
// for unrecoverable errors.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app").Enable()
|
||||
// logger.Fields("user", "alice").Fatalf("Fatal %s", "error") // Output: [app] ERROR: Fatal error [user=alice stack=...], then exits
|
||||
func (fb *FieldBuilder) Fatalf(format string, args ...any) {
|
||||
// Skip logging if fields are nil
|
||||
if fb.fields == nil {
|
||||
return
|
||||
}
|
||||
// Format the message and pass to Fatal
|
||||
fb.Fatal(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// Panic logs a message at Error level with a stack trace and the builder's fields, then panics.
|
||||
// It constructs the message from variadic arguments, logs it with a stack trace, and triggers
|
||||
// a panic with the message. Returns early if fields are nil. This method is used for critical
|
||||
// errors that require immediate program termination with a panic.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app").Enable()
|
||||
// logger.Fields("user", "alice").Panic("Panic", "error") // Output: [app] ERROR: Panic error [user=alice stack=...], then panics
|
||||
func (fb *FieldBuilder) Panic(args ...any) {
|
||||
// Skip logging if fields are nil
|
||||
if fb.fields == nil {
|
||||
return
|
||||
}
|
||||
// Build the message by concatenating arguments with spaces
|
||||
var builder strings.Builder
|
||||
for i, arg := range args {
|
||||
if i > 0 {
|
||||
builder.WriteString(lx.Space)
|
||||
}
|
||||
builder.WriteString(fmt.Sprint(arg))
|
||||
}
|
||||
msg := builder.String()
|
||||
// Log at Error level with the builder's fields and a stack trace
|
||||
fb.logger.log(lx.LevelError, lx.ClassText, msg, fb.fields, true)
|
||||
// Trigger a panic with the formatted message
|
||||
panic(msg)
|
||||
}
|
||||
|
||||
// Panicf logs a formatted message at Error level with a stack trace and the builder's fields,
|
||||
// then panics. It delegates to Panic and returns early if fields are nil. This method is used
|
||||
// for critical errors that require immediate program termination with a panic.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app").Enable()
|
||||
// logger.Fields("user", "alice").Panicf("Panic %s", "error") // Output: [app] ERROR: Panic error [user=alice stack=...], then panics
|
||||
func (fb *FieldBuilder) Panicf(format string, args ...any) {
|
||||
// Skip logging if fields are nil
|
||||
if fb.fields == nil {
|
||||
return
|
||||
}
|
||||
// Format the message and pass to Panic
|
||||
fb.Panic(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// Err adds one or more errors to the FieldBuilder as a field and logs them.
|
||||
// It stores non-nil errors in the "error" field: a single error if only one is non-nil,
|
||||
// or a slice of errors if multiple are non-nil. It logs the concatenated string representations
|
||||
// of non-nil errors (e.g., "failed 1; failed 2") at the Error level. Returns the FieldBuilder
|
||||
// for chaining, allowing further field additions or logging. Thread-safe via the logger's mutex.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app").Enable()
|
||||
// err1 := errors.New("failed 1")
|
||||
// err2 := errors.New("failed 2")
|
||||
// logger.Fields("k", "v").Err(err1, err2).Info("Error occurred")
|
||||
// // Output: [app] ERROR: failed 1; failed 2
|
||||
// // [app] INFO: Error occurred [error=[failed 1 failed 2] k=v]
|
||||
func (fb *FieldBuilder) Err(errs ...error) *FieldBuilder {
|
||||
// Initialize fields slice if nil
|
||||
if fb.fields == nil {
|
||||
fb.fields = make(lx.Fields, 0, 4)
|
||||
}
|
||||
|
||||
// Collect non-nil errors and build log message
|
||||
var nonNilErrors []error
|
||||
var builder strings.Builder
|
||||
count := 0
|
||||
for i, err := range errs {
|
||||
if err != nil {
|
||||
if i > 0 && count > 0 {
|
||||
builder.WriteString("; ")
|
||||
}
|
||||
builder.WriteString(err.Error())
|
||||
nonNilErrors = append(nonNilErrors, err)
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
// Set error field and log if there are non-nil errors
|
||||
if count > 0 {
|
||||
if count == 1 {
|
||||
// Store single error directly
|
||||
fb.fields = append(fb.fields, lx.Field{Key: "error", Value: nonNilErrors[0]})
|
||||
} else {
|
||||
// Store slice of errors
|
||||
fb.fields = append(fb.fields, lx.Field{Key: "error", Value: nonNilErrors})
|
||||
}
|
||||
// Log concatenated error messages at Error level
|
||||
fb.logger.log(lx.LevelError, lx.ClassText, builder.String(), nil, false)
|
||||
}
|
||||
|
||||
// Return FieldBuilder for chaining
|
||||
return fb
|
||||
}
|
||||
|
||||
// Merge adds additional key-value pairs to the FieldBuilder.
|
||||
// It processes variadic arguments as key-value pairs, expecting string keys. Non-string keys
|
||||
// or uneven pairs generate an "error" field with a descriptive message. Returns the FieldBuilder
|
||||
// for chaining to allow further field additions or logging.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app").Enable()
|
||||
// logger.Fields("k1", "v1").Merge("k2", "v2").Info("Action") // Output: [app] INFO: Action [k1=v1 k2=v2]
|
||||
func (fb *FieldBuilder) Merge(pairs ...any) *FieldBuilder {
|
||||
// Initialize fields slice if nil
|
||||
if fb.fields == nil {
|
||||
fb.fields = make(lx.Fields, 0, len(pairs)/2)
|
||||
}
|
||||
|
||||
// Process pairs as key-value, advancing by 2
|
||||
for i := 0; i < len(pairs)-1; i += 2 {
|
||||
// Ensure the key is a string
|
||||
if key, ok := pairs[i].(string); ok {
|
||||
fb.fields = append(fb.fields, lx.Field{Key: key, Value: pairs[i+1]})
|
||||
} else {
|
||||
// Log an error field for non-string keys
|
||||
fb.fields = append(fb.fields, lx.Field{
|
||||
Key: "error",
|
||||
Value: fmt.Errorf("non-string key in Merge: %v", pairs[i]),
|
||||
})
|
||||
}
|
||||
}
|
||||
// Check for uneven pairs (missing value)
|
||||
if len(pairs)%2 != 0 {
|
||||
fb.fields = append(fb.fields, lx.Field{
|
||||
Key: "error",
|
||||
Value: fmt.Errorf("uneven key-value pairs in Merge: [%v]", pairs[len(pairs)-1]),
|
||||
})
|
||||
}
|
||||
return fb
|
||||
}
|
||||
+707
@@ -0,0 +1,707 @@
|
||||
package ll
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/olekukonko/ll/lx"
|
||||
)
|
||||
|
||||
// defaultLogger is the global logger instance for package-level logging functions.
|
||||
// It provides a shared logger for convenience, allowing logging without explicitly creating
|
||||
// a logger instance. The logger is initialized with default settings: enabled, Debug level,
|
||||
// flat namespace style, and a text handler to os.Stdout. It is thread-safe due to the Logger
|
||||
// struct’s mutex.
|
||||
var defaultLogger = New("")
|
||||
|
||||
// Handler sets the handler for the default logger.
|
||||
// It configures the output destination and format (e.g., text, JSON) for logs emitted by
|
||||
// defaultLogger. Returns the default logger for method chaining, enabling fluent configuration.
|
||||
// Example:
|
||||
//
|
||||
// ll.Handler(lh.NewJSONHandler(os.Stdout)).Enable()
|
||||
// ll.Info("Started") // Output: {"level":"INFO","message":"Started"}
|
||||
func Handler(handler lx.Handler) *Logger {
|
||||
return defaultLogger.Handler(handler)
|
||||
}
|
||||
|
||||
// Level sets the minimum log level for the default logger.
|
||||
// It determines which log messages (Debug, Info, Warn, Error) are emitted. Messages below
|
||||
// the specified level are ignored. Returns the default logger for method chaining.
|
||||
// Example:
|
||||
//
|
||||
// ll.Level(lx.LevelWarn)
|
||||
// ll.Info("Ignored") // No output
|
||||
// ll.Warn("Logged") // Output: [] WARN: Logged
|
||||
func Level(level lx.LevelType) *Logger {
|
||||
return defaultLogger.Level(level)
|
||||
}
|
||||
|
||||
// Style sets the namespace style for the default logger.
|
||||
// It controls how namespace paths are formatted in logs (FlatPath: [parent/child],
|
||||
// NestedPath: [parent]→[child]). Returns the default logger for method chaining.
|
||||
// Example:
|
||||
//
|
||||
// ll.Style(lx.NestedPath)
|
||||
// ll.Info("Test") // Output: []: INFO: Test
|
||||
func Style(style lx.StyleType) *Logger {
|
||||
return defaultLogger.Style(style)
|
||||
}
|
||||
|
||||
// NamespaceEnable enables logging for a namespace and its children using the default logger.
|
||||
// It activates logging for the specified namespace path (e.g., "app/db") and all its
|
||||
// descendants. Returns the default logger for method chaining. Thread-safe via the Logger’s mutex.
|
||||
// Example:
|
||||
//
|
||||
// ll.NamespaceEnable("app/db")
|
||||
// ll.Clone().Namespace("db").Info("Query") // Output: [app/db] INFO: Query
|
||||
func NamespaceEnable(path string) *Logger {
|
||||
return defaultLogger.NamespaceEnable(path)
|
||||
}
|
||||
|
||||
// NamespaceDisable disables logging for a namespace and its children using the default logger.
|
||||
// It suppresses logging for the specified namespace path and all its descendants. Returns
|
||||
// the default logger for method chaining. Thread-safe via the Logger’s mutex.
|
||||
// Example:
|
||||
//
|
||||
// ll.NamespaceDisable("app/db")
|
||||
// ll.Clone().Namespace("db").Info("Query") // No output
|
||||
func NamespaceDisable(path string) *Logger {
|
||||
return defaultLogger.NamespaceDisable(path)
|
||||
}
|
||||
|
||||
// Namespace creates a child logger with a sub-namespace appended to the current path.
|
||||
// The child inherits the default logger’s configuration but has an independent context.
|
||||
// Thread-safe with read lock. Returns the new logger for further configuration or logging.
|
||||
// Example:
|
||||
//
|
||||
// logger := ll.Namespace("app")
|
||||
// logger.Info("Started") // Output: [app] INFO: Started
|
||||
func Namespace(name string) *Logger {
|
||||
return defaultLogger.Namespace(name)
|
||||
}
|
||||
|
||||
// Info logs a message at Info level with variadic arguments using the default logger.
|
||||
// It concatenates the arguments with spaces and delegates to defaultLogger’s Info method.
|
||||
// Thread-safe via the Logger’s log method.
|
||||
// Example:
|
||||
//
|
||||
// ll.Info("Service", "started") // Output: [] INFO: Service started
|
||||
func Info(args ...any) {
|
||||
defaultLogger.Info(args...)
|
||||
}
|
||||
|
||||
// Infof logs a message at Info level with a format string using the default logger.
|
||||
// It formats the message using the provided format string and arguments, then delegates to
|
||||
// defaultLogger’s Infof method. Thread-safe via the Logger’s log method.
|
||||
// Example:
|
||||
//
|
||||
// ll.Infof("Service %s", "started") // Output: [] INFO: Service started
|
||||
func Infof(format string, args ...any) {
|
||||
defaultLogger.Infof(format, args...)
|
||||
}
|
||||
|
||||
// Debug logs a message at Debug level with variadic arguments using the default logger.
|
||||
// It concatenates the arguments with spaces and delegates to defaultLogger’s Debug method.
|
||||
// Used for debugging information, typically disabled in production. Thread-safe.
|
||||
// Example:
|
||||
//
|
||||
// ll.Level(lx.LevelDebug)
|
||||
// ll.Debug("Debugging", "mode") // Output: [] DEBUG: Debugging mode
|
||||
func Debug(args ...any) {
|
||||
defaultLogger.Debug(args...)
|
||||
}
|
||||
|
||||
// Debugf logs a message at Debug level with a format string using the default logger.
|
||||
// It formats the message and delegates to defaultLogger’s Debugf method. Used for debugging
|
||||
// information, typically disabled in production. Thread-safe.
|
||||
// Example:
|
||||
//
|
||||
// ll.Level(lx.LevelDebug)
|
||||
// ll.Debugf("Debug %s", "mode") // Output: [] DEBUG: Debug mode
|
||||
func Debugf(format string, args ...any) {
|
||||
defaultLogger.Debugf(format, args...)
|
||||
}
|
||||
|
||||
// Warn logs a message at Warn level with variadic arguments using the default logger.
|
||||
// It concatenates the arguments with spaces and delegates to defaultLogger’s Warn method.
|
||||
// Used for warning conditions that do not halt execution. Thread-safe.
|
||||
// Example:
|
||||
//
|
||||
// ll.Warn("Low", "memory") // Output: [] WARN: Low memory
|
||||
func Warn(args ...any) {
|
||||
defaultLogger.Warn(args...)
|
||||
}
|
||||
|
||||
// Warnf logs a message at Warn level with a format string using the default logger.
|
||||
// It formats the message and delegates to defaultLogger’s Warnf method. Used for warning
|
||||
// conditions that do not halt execution. Thread-safe.
|
||||
// Example:
|
||||
//
|
||||
// ll.Warnf("Low %s", "memory") // Output: [] WARN: Low memory
|
||||
func Warnf(format string, args ...any) {
|
||||
defaultLogger.Warnf(format, args...)
|
||||
}
|
||||
|
||||
// Error logs a message at Error level with variadic arguments using the default logger.
|
||||
// It concatenates the arguments with spaces and delegates to defaultLogger’s Error method.
|
||||
// Used for error conditions requiring attention. Thread-safe.
|
||||
// Example:
|
||||
//
|
||||
// ll.Error("Database", "failure") // Output: [] ERROR: Database failure
|
||||
func Error(args ...any) {
|
||||
defaultLogger.Error(args...)
|
||||
}
|
||||
|
||||
// Errorf logs a message at Error level with a format string using the default logger.
|
||||
// It formats the message and delegates to defaultLogger’s Errorf method. Used for error
|
||||
// conditions requiring attention. Thread-safe.
|
||||
// Example:
|
||||
//
|
||||
// ll.Errorf("Database %s", "failure") // Output: [] ERROR: Database failure
|
||||
func Errorf(format string, args ...any) {
|
||||
defaultLogger.Errorf(format, args...)
|
||||
}
|
||||
|
||||
// Stack logs a message at Error level with a stack trace and variadic arguments using the default logger.
|
||||
// It concatenates the arguments with spaces and delegates to defaultLogger’s Stack method.
|
||||
// Thread-safe.
|
||||
// Example:
|
||||
//
|
||||
// ll.Stack("Critical", "error") // Output: [] ERROR: Critical error [stack=...]
|
||||
func Stack(args ...any) {
|
||||
defaultLogger.Stack(args...)
|
||||
}
|
||||
|
||||
// Stackf logs a message at Error level with a stack trace and a format string using the default logger.
|
||||
// It formats the message and delegates to defaultLogger’s Stackf method. Thread-safe.
|
||||
// Example:
|
||||
//
|
||||
// ll.Stackf("Critical %s", "error") // Output: [] ERROR: Critical error [stack=...]
|
||||
func Stackf(format string, args ...any) {
|
||||
defaultLogger.Stackf(format, args...)
|
||||
}
|
||||
|
||||
// Fatal logs a message at Error level with a stack trace and variadic arguments using the default logger,
|
||||
// then exits. It concatenates the arguments with spaces, logs with a stack trace, and terminates
|
||||
// with exit code 1. Thread-safe.
|
||||
// Example:
|
||||
//
|
||||
// ll.Fatal("Fatal", "error") // Output: [] ERROR: Fatal error [stack=...], then exits
|
||||
func Fatal(args ...any) {
|
||||
defaultLogger.Fatal(args...)
|
||||
}
|
||||
|
||||
// Fatalf logs a formatted message at Error level with a stack trace using the default logger,
|
||||
// then exits. It formats the message, logs with a stack trace, and terminates with exit code 1.
|
||||
// Thread-safe.
|
||||
// Example:
|
||||
//
|
||||
// ll.Fatalf("Fatal %s", "error") // Output: [] ERROR: Fatal error [stack=...], then exits
|
||||
func Fatalf(format string, args ...any) {
|
||||
defaultLogger.Fatalf(format, args...)
|
||||
}
|
||||
|
||||
// Panic logs a message at Error level with a stack trace and variadic arguments using the default logger,
|
||||
// then panics. It concatenates the arguments with spaces, logs with a stack trace, and triggers a panic.
|
||||
// Thread-safe.
|
||||
// Example:
|
||||
//
|
||||
// ll.Panic("Panic", "error") // Output: [] ERROR: Panic error [stack=...], then panics
|
||||
func Panic(args ...any) {
|
||||
defaultLogger.Panic(args...)
|
||||
}
|
||||
|
||||
// Panicf logs a formatted message at Error level with a stack trace using the default logger,
|
||||
// then panics. It formats the message, logs with a stack trace, and triggers a panic. Thread-safe.
|
||||
// Example:
|
||||
//
|
||||
// ll.Panicf("Panic %s", "error") // Output: [] ERROR: Panic error [stack=...], then panics
|
||||
func Panicf(format string, args ...any) {
|
||||
defaultLogger.Panicf(format, args...)
|
||||
}
|
||||
|
||||
// If creates a conditional logger that logs only if the condition is true using the default logger.
|
||||
func If(condition bool) *Conditional {
|
||||
return defaultLogger.If(condition)
|
||||
}
|
||||
|
||||
// IfErr creates a conditional logger that logs only if the error is non-nil using the default logger.
|
||||
func IfErr(err error) *Conditional {
|
||||
return defaultLogger.IfErr(err)
|
||||
}
|
||||
|
||||
// IfErrAny creates a conditional logger that logs only if AT LEAST ONE error is non-nil using the default logger.
|
||||
func IfErrAny(errs ...error) *Conditional {
|
||||
return defaultLogger.IfErrAny(errs...)
|
||||
}
|
||||
|
||||
// IfErrOne creates a conditional logger that logs only if ALL errors are non-nil using the default logger.
|
||||
func IfErrOne(errs ...error) *Conditional {
|
||||
return defaultLogger.IfErrOne(errs...)
|
||||
}
|
||||
|
||||
// Context creates a new logger with additional contextual fields using the default logger.
|
||||
// It preserves existing context fields and adds new ones, returning a new logger instance
|
||||
// to avoid mutating the default logger. Thread-safe with write lock.
|
||||
// Example:
|
||||
//
|
||||
// logger := ll.Context(map[string]interface{}{"user": "alice"})
|
||||
// logger.Info("Action") // Output: [] INFO: Action [user=alice]
|
||||
func Context(fields map[string]interface{}) *Logger {
|
||||
return defaultLogger.Context(fields)
|
||||
}
|
||||
|
||||
// AddContext adds a key-value pair to the default logger’s context, modifying it directly.
|
||||
// It mutates the default logger’s context and is thread-safe using a write lock.
|
||||
// Example:
|
||||
//
|
||||
// ll.AddContext("user", "alice")
|
||||
// ll.Info("Action") // Output: [] INFO: Action [user=alice]
|
||||
func AddContext(pairs ...any) *Logger {
|
||||
return defaultLogger.AddContext(pairs...)
|
||||
}
|
||||
|
||||
// GetContext returns the default logger’s context map of persistent key-value fields.
|
||||
// It provides thread-safe read access to the context using a read lock.
|
||||
// Example:
|
||||
//
|
||||
// ll.AddContext("user", "alice")
|
||||
// ctx := ll.GetContext() // Returns map[string]interface{}{"user": "alice"}k
|
||||
func GetContext() map[string]interface{} {
|
||||
return defaultLogger.GetContext()
|
||||
}
|
||||
|
||||
// GetLevel returns the minimum log level for the default logger.
|
||||
// It provides thread-safe read access to the level field using a read lock.
|
||||
// Example:
|
||||
//
|
||||
// ll.Level(lx.LevelWarn)
|
||||
// if ll.GetLevel() == lx.LevelWarn {
|
||||
// ll.Warn("Warning level set") // Output: [] WARN: Warning level set
|
||||
// }
|
||||
func GetLevel() lx.LevelType {
|
||||
return defaultLogger.GetLevel()
|
||||
}
|
||||
|
||||
// GetPath returns the default logger’s current namespace path.
|
||||
// It provides thread-safe read access to the currentPath field using a read lock.
|
||||
// Example:
|
||||
//
|
||||
// logger := ll.Namespace("app")
|
||||
// path := logger.GetPath() // Returns "app"
|
||||
func GetPath() string {
|
||||
return defaultLogger.GetPath()
|
||||
}
|
||||
|
||||
// GetSeparator returns the default logger’s namespace separator (e.g., "/").
|
||||
// It provides thread-safe read access to the separator field using a read lock.
|
||||
// Example:
|
||||
//
|
||||
// ll.Separator(".")
|
||||
// sep := ll.GetSeparator() // Returns "."
|
||||
func GetSeparator() string {
|
||||
return defaultLogger.GetSeparator()
|
||||
}
|
||||
|
||||
// GetStyle returns the default logger’s namespace formatting style (FlatPath or NestedPath).
|
||||
// It provides thread-safe read access to the style field using a read lock.
|
||||
// Example:
|
||||
//
|
||||
// ll.Style(lx.NestedPath)
|
||||
// if ll.GetStyle() == lx.NestedPath {
|
||||
// ll.Info("Nested style") // Output: []: INFO: Nested style
|
||||
// }
|
||||
func GetStyle() lx.StyleType {
|
||||
return defaultLogger.GetStyle()
|
||||
}
|
||||
|
||||
// GetHandler returns the default logger’s current handler for customization or inspection.
|
||||
// The returned handler should not be modified concurrently with logger operations.
|
||||
// Example:
|
||||
//
|
||||
// handler := ll.GetHandler() // Returns the current handler (e.g., TextHandler)
|
||||
func GetHandler() lx.Handler {
|
||||
return defaultLogger.GetHandler()
|
||||
}
|
||||
|
||||
// Separator sets the namespace separator for the default logger (e.g., "/" or ".").
|
||||
// It updates the separator used in namespace paths. Thread-safe with write lock.
|
||||
// Returns the default logger for method chaining.
|
||||
// Example:
|
||||
//
|
||||
// ll.Separator(".")
|
||||
// ll.Namespace("app").Info("Log") // Output: [app] INFO: Log
|
||||
func Separator(separator string) *Logger {
|
||||
return defaultLogger.Separator(separator)
|
||||
}
|
||||
|
||||
// Prefix sets a prefix to be prepended to all log messages of the default logger.
|
||||
// The prefix is applied before the message in the log output. Thread-safe with write lock.
|
||||
// Returns the default logger for method chaining.
|
||||
// Example:
|
||||
//
|
||||
// ll.Prefix("APP: ")
|
||||
// ll.Info("Started") // Output: [] INFO: APP: Started
|
||||
func Prefix(prefix string) *Logger {
|
||||
return defaultLogger.Prefix(prefix)
|
||||
}
|
||||
|
||||
// StackSize sets the buffer size for stack trace capture in the default logger.
|
||||
// It configures the maximum size for stack traces in Stack, Fatal, and Panic methods.
|
||||
// Thread-safe with write lock. Returns the default logger for chaining.
|
||||
// Example:
|
||||
//
|
||||
// ll.StackSize(65536)
|
||||
// ll.Stack("Error") // Captures up to 64KB stack trace
|
||||
func StackSize(size int) *Logger {
|
||||
return defaultLogger.StackSize(size)
|
||||
}
|
||||
|
||||
// Use adds a middleware function to process log entries before they are handled by the default logger.
|
||||
// It registers the middleware and returns a Middleware handle for removal. Middleware returning
|
||||
// a non-nil error stops the log. Thread-safe with write lock.
|
||||
// Example:
|
||||
//
|
||||
// mw := ll.Use(ll.FuncMiddleware(func(e *lx.Entry) error {
|
||||
// if e.Level < lx.LevelWarn {
|
||||
// return fmt.Errorf("level too low")
|
||||
// }
|
||||
// return nil
|
||||
// }))
|
||||
// ll.Info("Ignored") // No output
|
||||
// mw.Remove()
|
||||
// ll.Info("Logged") // Output: [] INFO: Logged
|
||||
func Use(fn lx.Handler) *Middleware {
|
||||
return defaultLogger.Use(fn)
|
||||
}
|
||||
|
||||
// Remove removes middleware by the reference returned from Use for the default logger.
|
||||
// It delegates to the Middleware’s Remove method for thread-safe removal.
|
||||
// Example:
|
||||
//
|
||||
// mw := ll.Use(someMiddleware)
|
||||
// ll.Remove(mw) // Removes middleware
|
||||
func Remove(m *Middleware) {
|
||||
defaultLogger.Remove(m)
|
||||
}
|
||||
|
||||
// Clear removes all middleware functions from the default logger.
|
||||
// It resets the middleware chain to empty, ensuring no middleware is applied.
|
||||
// Thread-safe with write lock. Returns the default logger for chaining.
|
||||
// Example:
|
||||
//
|
||||
// ll.Use(someMiddleware)
|
||||
// ll.Clear()
|
||||
// ll.Info("No middleware") // Output: [] INFO: No middleware
|
||||
func Clear() *Logger {
|
||||
return defaultLogger.Clear()
|
||||
}
|
||||
|
||||
// CanLog checks if a log at the given level would be emitted by the default logger.
|
||||
// It considers enablement, log level, namespaces, sampling, and rate limits.
|
||||
// Thread-safe via the Logger’s shouldLog method.
|
||||
// Example:
|
||||
//
|
||||
// ll.Level(lx.LevelWarn)
|
||||
// canLog := ll.CanLog(lx.LevelInfo) // false
|
||||
func CanLog(level lx.LevelType) bool {
|
||||
return defaultLogger.CanLog(level)
|
||||
}
|
||||
|
||||
// NamespaceEnabled checks if a namespace is enabled in the default logger.
|
||||
// It evaluates the namespace hierarchy, considering parent namespaces, and caches the result
|
||||
// for performance. Thread-safe with read lock.
|
||||
// Example:
|
||||
//
|
||||
// ll.NamespaceDisable("app/db")
|
||||
// enabled := ll.NamespaceEnabled("app/db") // false
|
||||
func NamespaceEnabled(path string) bool {
|
||||
return defaultLogger.NamespaceEnabled(path)
|
||||
}
|
||||
|
||||
// Print logs a message at Info level without format specifiers using the default logger.
|
||||
// It concatenates variadic arguments with spaces, minimizing allocations, and delegates
|
||||
// to defaultLogger’s Print method. Thread-safe via the Logger’s log method.
|
||||
// Example:
|
||||
//
|
||||
// ll.Print("message", "value") // Output: [] INFO: message value
|
||||
func Print(args ...any) {
|
||||
defaultLogger.Print(args...)
|
||||
}
|
||||
|
||||
// Println logs a message at Info level without format specifiers, minimizing allocations
|
||||
// by concatenating arguments with spaces. It is thread-safe via the log method.
|
||||
// Example:
|
||||
//
|
||||
// ll.Println("message", "value") // Output: [] INFO: message value [New Line]
|
||||
func Println(args ...any) {
|
||||
defaultLogger.Println(args...)
|
||||
}
|
||||
|
||||
// Printf logs a message at Info level with a format string using the default logger.
|
||||
// It formats the message and delegates to defaultLogger’s Printf method. Thread-safe via
|
||||
// the Logger’s log method.
|
||||
// Example:
|
||||
//
|
||||
// ll.Printf("Message %s", "value") // Output: [] INFO: Message value
|
||||
func Printf(format string, args ...any) {
|
||||
defaultLogger.Printf(format, args...)
|
||||
}
|
||||
|
||||
// Len returns the total number of log entries sent to the handler by the default logger.
|
||||
// It provides thread-safe access to the entries counter using atomic operations.
|
||||
// Example:
|
||||
//
|
||||
// ll.Info("Test")
|
||||
// count := ll.Len() // Returns 1
|
||||
func Len() int64 {
|
||||
return defaultLogger.Len()
|
||||
}
|
||||
|
||||
// Measure is a benchmarking helper that measures and returns the duration of a function’s execution.
|
||||
// It logs the duration at Info level with a "duration" field using defaultLogger. The function
|
||||
// is executed once, and the elapsed time is returned. Thread-safe via the Logger’s mutex.
|
||||
// Example:
|
||||
//
|
||||
// duration := ll.Measure(func() { time.Sleep(time.Millisecond) })
|
||||
// // Output: [] INFO: function executed [duration=~1ms]
|
||||
func Measure(fns ...func()) time.Duration {
|
||||
return defaultLogger.Measure(fns...)
|
||||
}
|
||||
|
||||
// Labels temporarily attaches one or more label names to the logger for the next log entry.
|
||||
// Labels are typically used for metrics, benchmarking, tracing, or categorizing logs in a structured way.
|
||||
//
|
||||
// The labels are stored atomically and intended to be short-lived, applying only to the next
|
||||
// log operation (or until overwritten by a subsequent call to Labels). Multiple labels can
|
||||
// be provided as separate string arguments.
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// logger := New("app").Enable()
|
||||
//
|
||||
// // Add labels for a specific operation
|
||||
// logger.Labels("load_users", "process_orders").Measure(func() {
|
||||
// // ... perform work ...
|
||||
// }, func() {
|
||||
// // ... optional callback ...
|
||||
// })
|
||||
func Labels(names ...string) *Logger {
|
||||
return defaultLogger.Labels(names...)
|
||||
}
|
||||
|
||||
// Since creates a timer that will log the duration when completed
|
||||
// If startTime is provided, uses that as the start time; otherwise uses time.Now()
|
||||
//
|
||||
// defer logger.Since().Info("request") // Auto-start
|
||||
// logger.Since(start).Info("request") // Manual timing
|
||||
// logger.Since().If(debug).Debug("timing") // Conditional
|
||||
func Since(start ...time.Time) *SinceBuilder {
|
||||
return defaultLogger.Since(start...)
|
||||
}
|
||||
|
||||
// Benchmark logs the duration since a start time at Info level using the default logger.
|
||||
// It calculates the time elapsed since the provided start time and logs it with "start",
|
||||
// "end", and "duration" fields. Thread-safe via the Logger’s mutex.
|
||||
// Example:
|
||||
//
|
||||
// start := time.Now()
|
||||
// time.Sleep(time.Millisecond)
|
||||
// ll.Benchmark(start) // Output: [] INFO: benchmark [start=... end=... duration=...]
|
||||
func Benchmark(start time.Time) {
|
||||
defaultLogger.Benchmark(start)
|
||||
}
|
||||
|
||||
// Clone returns a new logger with the same configuration as the default logger.
|
||||
// It creates a copy of defaultLogger’s settings (level, style, namespaces, etc.) but with
|
||||
// an independent context, allowing customization without affecting the global logger.
|
||||
// Thread-safe via the Logger’s Clone method.
|
||||
// Example:
|
||||
//
|
||||
// logger := ll.Clone().Namespace("sub")
|
||||
// logger.Info("Sub-logger") // Output: [sub] INFO: Sub-logger
|
||||
func Clone() *Logger {
|
||||
return defaultLogger.Clone()
|
||||
}
|
||||
|
||||
// Err adds one or more errors to the default logger’s context and logs them.
|
||||
// It stores non-nil errors in the "error" context field and logs their concatenated string
|
||||
// representations (e.g., "failed 1; failed 2") at the Error level. Thread-safe via the Logger’s mutex.
|
||||
// Example:
|
||||
//
|
||||
// err1 := errors.New("failed 1")
|
||||
// ll.Err(err1)
|
||||
// ll.Info("Error occurred") // Output: [] ERROR: failed 1
|
||||
// // [] INFO: Error occurred [error=failed 1]
|
||||
func Err(errs ...error) {
|
||||
defaultLogger.Err(errs...)
|
||||
}
|
||||
|
||||
// Start activates the global logging system.
|
||||
// If the system was shut down, this re-enables all logging operations,
|
||||
// subject to individual logger and namespace configurations.
|
||||
// Thread-safe via atomic operations.
|
||||
// Example:
|
||||
//
|
||||
// ll.Shutdown()
|
||||
// ll.Info("Ignored") // No output
|
||||
// ll.Start()
|
||||
// ll.Info("Logged") // Output: [] INFO: Logged
|
||||
func Start() {
|
||||
atomic.StoreInt32(&systemActive, 1)
|
||||
}
|
||||
|
||||
// Shutdown deactivates the global logging system.
|
||||
// All logging operations are skipped, regardless of individual logger or namespace configurations,
|
||||
// until Start() is called again. Thread-safe via atomic operations.
|
||||
// Example:
|
||||
//
|
||||
// ll.Shutdown()
|
||||
// ll.Info("Ignored") // No output
|
||||
func Shutdown() {
|
||||
atomic.StoreInt32(&systemActive, 0)
|
||||
}
|
||||
|
||||
// Active returns true if the global logging system is currently active.
|
||||
// Thread-safe via atomic operations.
|
||||
// Example:
|
||||
//
|
||||
// if ll.Active() {
|
||||
// ll.Info("System active") // Output: [] INFO: System active
|
||||
// }
|
||||
func Active() bool {
|
||||
return atomic.LoadInt32(&systemActive) == 1
|
||||
}
|
||||
|
||||
// Enable activates logging for the default logger.
|
||||
// It allows logs to be emitted if other conditions (level, namespace) are met.
|
||||
// Thread-safe with write lock. Returns the default logger for method chaining.
|
||||
// Example:
|
||||
//
|
||||
// ll.Disable()
|
||||
// ll.Info("Ignored") // No output
|
||||
// ll.Enable()
|
||||
// ll.Info("Logged") // Output: [] INFO: Logged
|
||||
func Enable() *Logger {
|
||||
return defaultLogger.Enable()
|
||||
}
|
||||
|
||||
// Disable deactivates logging for the default logger.
|
||||
// It suppresses all logs, regardless of level or namespace. Thread-safe with write lock.
|
||||
// Returns the default logger for method chaining.
|
||||
// Example:
|
||||
//
|
||||
// ll.Disable()
|
||||
// ll.Info("Ignored") // No output
|
||||
func Disable() *Logger {
|
||||
return defaultLogger.Disable()
|
||||
}
|
||||
|
||||
// Dbg logs debug information including the source file, line number, and expression value
|
||||
// using the default logger. It captures the calling line of code and displays both the
|
||||
// expression and its value. Useful for debugging without temporary print statements.
|
||||
// Example:
|
||||
//
|
||||
// x := 42
|
||||
// ll.Dbg(x) // Output: [file.go:123] x = 42
|
||||
func Dbg(any ...interface{}) {
|
||||
defaultLogger.dbg(2, any...)
|
||||
}
|
||||
|
||||
// Dump displays a hex and ASCII representation of a value’s binary form using the default logger.
|
||||
// It serializes the value using gob encoding or direct conversion and shows a hex/ASCII dump.
|
||||
// Useful for inspecting binary data structures.
|
||||
// Example:
|
||||
//
|
||||
// ll.Dump([]byte{0x41, 0x42}) // Outputs hex/ASCII dump
|
||||
func Dump(values ...interface{}) {
|
||||
defaultLogger.Dump(values...)
|
||||
}
|
||||
|
||||
// Enabled returns whether the default logger is enabled for logging.
|
||||
// It provides thread-safe read access to the enabled field using a read lock.
|
||||
// Example:
|
||||
//
|
||||
// ll.Enable()
|
||||
// if ll.Enabled() {
|
||||
// ll.Info("Logging enabled") // Output: [] INFO: Logging enabled
|
||||
// }
|
||||
func Enabled() bool {
|
||||
return defaultLogger.Enabled()
|
||||
}
|
||||
|
||||
// Fields starts a fluent chain for adding fields using variadic key-value pairs with the default logger.
|
||||
// It creates a FieldBuilder to attach fields, handling non-string keys or uneven pairs by
|
||||
// adding an error field. Thread-safe via the FieldBuilder’s logger.
|
||||
// Example:
|
||||
//
|
||||
// ll.Fields("user", "alice").Info("Action") // Output: [] INFO: Action [user=alice]
|
||||
func Fields(pairs ...any) *FieldBuilder {
|
||||
return defaultLogger.Fields(pairs...)
|
||||
}
|
||||
|
||||
// Field starts a fluent chain for adding fields from a map with the default logger.
|
||||
// It creates a FieldBuilder to attach fields from a map, supporting type-safe field addition.
|
||||
// Thread-safe via the FieldBuilder’s logger.
|
||||
// Example:
|
||||
//
|
||||
// ll.Field(map[string]interface{}{"user": "alice"}).Info("Action") // Output: [] INFO: Action [user=alice]
|
||||
func Field(fields map[string]interface{}) *FieldBuilder {
|
||||
return defaultLogger.Field(fields)
|
||||
}
|
||||
|
||||
// Line adds vertical spacing (newlines) to the log output using the default logger.
|
||||
// If no arguments are provided, it defaults to 1 newline. Multiple values are summed to
|
||||
// determine the total lines. Useful for visually separating log sections. Thread-safe.
|
||||
// Example:
|
||||
//
|
||||
// ll.Line(2).Info("After two newlines") // Adds 2 blank lines before: [] INFO: After two newlines
|
||||
func Line(lines ...int) *Logger {
|
||||
return defaultLogger.Line(lines...)
|
||||
}
|
||||
|
||||
// Indent sets the indentation level for all log messages of the default logger.
|
||||
// Each level adds two spaces to the log message, useful for hierarchical output.
|
||||
// Thread-safe with write lock. Returns the default logger for method chaining.
|
||||
// Example:
|
||||
//
|
||||
// ll.Indent(2)
|
||||
// ll.Info("Indented") // Output: [] INFO: Indented
|
||||
func Indent(depth int) *Logger {
|
||||
return defaultLogger.Indent(depth)
|
||||
}
|
||||
|
||||
// Mark logs the current file and line number where it's called, without any additional debug information.
|
||||
// It's useful for tracing execution flow without the verbosity of Dbg.
|
||||
// Example:
|
||||
//
|
||||
// logger.Mark() // *MARK*: [file.go:123]
|
||||
func Mark(names ...string) {
|
||||
defaultLogger.mark(2, names...)
|
||||
|
||||
}
|
||||
|
||||
// Output logs data in a human-readable JSON format at Info level, including caller file and line information.
|
||||
// It is similar to Dbg but formats the output as JSON for better readability. It is thread-safe and respects
|
||||
// the logger’s configuration (e.g., enabled, level, suspend, handler, middleware).
|
||||
func Output(values ...interface{}) {
|
||||
defaultLogger.output(2, values...)
|
||||
|
||||
}
|
||||
|
||||
// Inspect logs one or more values in a **developer-friendly, deeply introspective format** at Info level.
|
||||
// It includes the caller file and line number, and reveals **all fields** — including:
|
||||
func Inspect(values ...interface{}) {
|
||||
o := NewInspector(defaultLogger)
|
||||
o.Log(2, values...)
|
||||
}
|
||||
|
||||
func Apply(opts ...Option) *Logger {
|
||||
return defaultLogger.Apply(opts...)
|
||||
|
||||
}
|
||||
|
||||
func Toggle(v bool) *Logger {
|
||||
return defaultLogger.Toggle(v)
|
||||
}
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
package ll
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
"github.com/olekukonko/ll/lx"
|
||||
)
|
||||
|
||||
// Inspector is a utility for Logger that provides advanced inspection and logging of data
|
||||
// in human-readable JSON format. It uses reflection to access and represent unexported fields,
|
||||
// nested structs, embedded structs, and pointers, making it useful for debugging complex data structures.
|
||||
type Inspector struct {
|
||||
logger *Logger
|
||||
}
|
||||
|
||||
// NewInspector returns a new Inspector instance associated with the provided logger.
|
||||
func NewInspector(logger *Logger) *Inspector {
|
||||
return &Inspector{logger: logger}
|
||||
}
|
||||
|
||||
// Log outputs the given values as indented JSON at the Info level, prefixed with the caller's
|
||||
// file name and line number. It handles structs (including unexported fields, nested, and embedded),
|
||||
// pointers, errors, and other types. The skip parameter determines how many stack frames to skip
|
||||
// when identifying the caller; typically set to 2 to account for the call to Log and its wrapper.
|
||||
//
|
||||
// Example usage within a Logger method:
|
||||
//
|
||||
// o := NewInspector(l)
|
||||
// o.Log(2, someStruct)
|
||||
func (o *Inspector) Log(skip int, values ...interface{}) {
|
||||
// Skip if logger is suspended or Info level is disabled
|
||||
if o.logger.suspend.Load() || !o.logger.shouldLog(lx.LevelInfo) {
|
||||
return
|
||||
}
|
||||
|
||||
// Retrieve caller information for logging context
|
||||
_, file, line, ok := runtime.Caller(skip)
|
||||
if !ok {
|
||||
o.logger.log(lx.LevelError, lx.ClassText, "Inspector: Unable to parse runtime caller", nil, false)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract short filename for concise output
|
||||
shortFile := file
|
||||
if idx := strings.LastIndex(file, "/"); idx >= 0 {
|
||||
shortFile = file[idx+1:]
|
||||
}
|
||||
|
||||
// Process each value individually
|
||||
for _, value := range values {
|
||||
var jsonData []byte
|
||||
var err error
|
||||
|
||||
// Use reflection for struct types to handle unexported and nested fields
|
||||
val := reflect.ValueOf(value)
|
||||
if val.Kind() == reflect.Ptr {
|
||||
val = val.Elem()
|
||||
}
|
||||
if val.Kind() == reflect.Struct {
|
||||
valueMap := o.structToMap(val)
|
||||
jsonData, err = json.MarshalIndent(valueMap, "", " ")
|
||||
} else if errVal, ok := value.(error); ok {
|
||||
// Special handling for errors to represent them as a simple map
|
||||
value = map[string]string{"error": errVal.Error()}
|
||||
jsonData, err = json.MarshalIndent(value, "", " ")
|
||||
} else {
|
||||
// Fall back to standard JSON marshaling for non-struct types
|
||||
jsonData, err = json.MarshalIndent(value, "", " ")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
o.logger.log(lx.LevelError, lx.ClassInspect, fmt.Sprintf("Inspector: JSON encoding error: %v", err), nil, false)
|
||||
continue
|
||||
}
|
||||
|
||||
// Construct log message with file, line, and JSON data
|
||||
msg := fmt.Sprintf("[%s:%d] %s", shortFile, line, string(jsonData))
|
||||
o.logger.log(lx.LevelInfo, lx.ClassInspect, msg, nil, false)
|
||||
}
|
||||
}
|
||||
|
||||
// structToMap recursively converts a struct's reflect.Value to a map[string]interface{}.
|
||||
// It includes unexported fields (named with parentheses), prefixes pointers with '*',
|
||||
// flattens anonymous embedded structs without json tags, and uses unsafe pointers to access
|
||||
// unexported primitive fields when reflect.CanInterface() returns false.
|
||||
func (o *Inspector) structToMap(val reflect.Value) map[string]interface{} {
|
||||
result := make(map[string]interface{})
|
||||
if !val.IsValid() {
|
||||
return result
|
||||
}
|
||||
|
||||
typ := val.Type()
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
field := val.Field(i)
|
||||
fieldType := typ.Field(i)
|
||||
|
||||
// Determine field name: prefer json tag if present and not "-", else use struct field name
|
||||
baseName := fieldType.Name
|
||||
jsonTag := fieldType.Tag.Get("json")
|
||||
hasJsonTag := false
|
||||
if jsonTag != "" {
|
||||
if idx := strings.Index(jsonTag, ","); idx != -1 {
|
||||
jsonTag = jsonTag[:idx]
|
||||
}
|
||||
if jsonTag != "-" {
|
||||
baseName = jsonTag
|
||||
hasJsonTag = true
|
||||
}
|
||||
}
|
||||
|
||||
// Enclose unexported field names in parentheses
|
||||
fieldName := baseName
|
||||
if !fieldType.IsExported() {
|
||||
fieldName = "(" + baseName + ")"
|
||||
}
|
||||
|
||||
// Handle pointer fields
|
||||
isPtr := fieldType.Type.Kind() == reflect.Ptr
|
||||
if isPtr {
|
||||
fieldName = "*" + fieldName
|
||||
if field.IsNil() {
|
||||
result[fieldName] = nil
|
||||
continue
|
||||
}
|
||||
field = field.Elem()
|
||||
}
|
||||
|
||||
// Recurse for struct fields
|
||||
if field.Kind() == reflect.Struct {
|
||||
subMap := o.structToMap(field)
|
||||
isNested := !fieldType.Anonymous || hasJsonTag
|
||||
if isNested {
|
||||
result[fieldName] = subMap
|
||||
} else {
|
||||
// Flatten embedded struct fields into the parent map, avoiding overwrites
|
||||
for k, v := range subMap {
|
||||
if _, exists := result[k]; !exists {
|
||||
result[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Handle primitive fields
|
||||
if field.CanInterface() {
|
||||
result[fieldName] = field.Interface()
|
||||
} else {
|
||||
// Use unsafe access for unexported primitives
|
||||
ptr := getDataPtr(field)
|
||||
switch field.Kind() {
|
||||
case reflect.String:
|
||||
result[fieldName] = *(*string)(ptr)
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
result[fieldName] = o.getIntFromUnexportedField(field)
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
result[fieldName] = o.getUintFromUnexportedField(field)
|
||||
case reflect.Float32, reflect.Float64:
|
||||
result[fieldName] = o.getFloatFromUnexportedField(field)
|
||||
case reflect.Bool:
|
||||
result[fieldName] = *(*bool)(ptr)
|
||||
default:
|
||||
result[fieldName] = fmt.Sprintf("*unexported %s*", field.Type().String())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// emptyInterface represents the internal structure of an empty interface{}.
|
||||
// This is used for unsafe pointer manipulation to access unexported field data.
|
||||
type emptyInterface struct {
|
||||
typ unsafe.Pointer
|
||||
word unsafe.Pointer
|
||||
}
|
||||
|
||||
// getDataPtr returns an unsafe.Pointer to the underlying data of a reflect.Value.
|
||||
// This enables direct access to unexported fields via unsafe operations.
|
||||
func getDataPtr(v reflect.Value) unsafe.Pointer {
|
||||
return (*emptyInterface)(unsafe.Pointer(&v)).word
|
||||
}
|
||||
|
||||
// getIntFromUnexportedField extracts a signed integer value from an unexported field
|
||||
// using unsafe pointer access. It supports int, int8, int16, int32, and int64 kinds,
|
||||
// returning the value as int64. Returns 0 for unsupported kinds.
|
||||
func (o *Inspector) getIntFromUnexportedField(field reflect.Value) int64 {
|
||||
ptr := getDataPtr(field)
|
||||
switch field.Kind() {
|
||||
case reflect.Int:
|
||||
return int64(*(*int)(ptr))
|
||||
case reflect.Int8:
|
||||
return int64(*(*int8)(ptr))
|
||||
case reflect.Int16:
|
||||
return int64(*(*int16)(ptr))
|
||||
case reflect.Int32:
|
||||
return int64(*(*int32)(ptr))
|
||||
case reflect.Int64:
|
||||
return *(*int64)(ptr)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// getUintFromUnexportedField extracts an unsigned integer value from an unexported field
|
||||
// using unsafe pointer access. It supports uint, uint8, uint16, uint32, and uint64 kinds,
|
||||
// returning the value as uint64. Returns 0 for unsupported kinds.
|
||||
func (o *Inspector) getUintFromUnexportedField(field reflect.Value) uint64 {
|
||||
ptr := getDataPtr(field)
|
||||
switch field.Kind() {
|
||||
case reflect.Uint:
|
||||
return uint64(*(*uint)(ptr))
|
||||
case reflect.Uint8:
|
||||
return uint64(*(*uint8)(ptr))
|
||||
case reflect.Uint16:
|
||||
return uint64(*(*uint16)(ptr))
|
||||
case reflect.Uint32:
|
||||
return uint64(*(*uint32)(ptr))
|
||||
case reflect.Uint64:
|
||||
return *(*uint64)(ptr)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// getFloatFromUnexportedField extracts a floating-point value from an unexported field
|
||||
// using unsafe pointer access. It supports float32 and float64 kinds, returning the value
|
||||
// as float64. Returns 0 for unsupported kinds.
|
||||
func (o *Inspector) getFloatFromUnexportedField(field reflect.Value) float64 {
|
||||
ptr := getDataPtr(field)
|
||||
switch field.Kind() {
|
||||
case reflect.Float32:
|
||||
return float64(*(*float32)(ptr))
|
||||
case reflect.Float64:
|
||||
return *(*float64)(ptr)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package ll
|
||||
|
||||
import "github.com/olekukonko/ll/lx"
|
||||
|
||||
// defaultStore is the global namespace store for enable/disable states.
|
||||
// It is shared across all Logger instances to manage namespace hierarchy consistently.
|
||||
// Thread-safe via the lx.Namespace struct’s sync.Map.
|
||||
var defaultStore = &lx.Namespace{}
|
||||
|
||||
// systemActive indicates if the global logging system is active.
|
||||
// Defaults to true, meaning logging is active unless explicitly shut down.
|
||||
// Or, default to false and require an explicit ll.Start(). Let's default to true for less surprise.
|
||||
var systemActive int32 = 1 // 1 for true, 0 for false (for atomic operations)
|
||||
|
||||
// Option defines a functional option for configuring a Logger.
|
||||
type Option func(*Logger)
|
||||
|
||||
// reverseString reverses the input string by swapping characters from both ends.
|
||||
// It converts the string to a rune slice to handle Unicode characters correctly,
|
||||
// ensuring proper reversal for multi-byte characters.
|
||||
// Used internally for string manipulation, such as in debugging or log formatting.
|
||||
func reverseString(s string) string {
|
||||
// Convert string to rune slice to handle Unicode characters
|
||||
r := []rune(s)
|
||||
// Iterate over half the slice, swapping characters from start and end
|
||||
for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
|
||||
r[i], r[j] = r[j], r[i]
|
||||
}
|
||||
// Convert rune slice back to string and return
|
||||
return string(r)
|
||||
}
|
||||
|
||||
// viewString converts a byte slice to a printable string, replacing non-printable
|
||||
// characters (ASCII < 32 or > 126) with a dot ('.').
|
||||
// It ensures safe display of binary data in logs, such as in the Dump method.
|
||||
// Used for formatting binary data in a human-readable hex/ASCII dump.
|
||||
func viewString(b []byte) string {
|
||||
// Convert byte slice to rune slice via string for processing
|
||||
r := []rune(string(b))
|
||||
// Replace non-printable characters with '.'
|
||||
for i := range r {
|
||||
if r[i] < 32 || r[i] > 126 {
|
||||
r[i] = '.'
|
||||
}
|
||||
}
|
||||
// Return the resulting printable string
|
||||
return string(r)
|
||||
}
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
package lh
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/olekukonko/ll/lx"
|
||||
)
|
||||
|
||||
// Buffering holds configuration for the Buffered handler.
|
||||
type Buffering struct {
|
||||
BatchSize int // Flush when this many entries are buffered (default: 100)
|
||||
FlushInterval time.Duration // Maximum time between flushes (default: 10s)
|
||||
MaxBuffer int // Maximum buffer size before applying backpressure (default: 1000)
|
||||
OnOverflow func(int) // Called when buffer reaches MaxBuffer (default: logs warning)
|
||||
ErrorOutput io.Writer // Destination for internal errors like flush failures (default: os.Stderr)
|
||||
}
|
||||
|
||||
// BufferingOpt configures Buffered handler.
|
||||
type BufferingOpt func(*Buffering)
|
||||
|
||||
// WithBatchSize sets the batch size for flushing.
|
||||
// It specifies the number of log entries to buffer before flushing to the underlying handler.
|
||||
// Example:
|
||||
//
|
||||
// handler := NewBuffered(textHandler, WithBatchSize(50)) // Flush every 50 entries
|
||||
func WithBatchSize(size int) BufferingOpt {
|
||||
return func(c *Buffering) {
|
||||
c.BatchSize = size
|
||||
}
|
||||
}
|
||||
|
||||
// WithFlushInterval sets the maximum time between flushes.
|
||||
// It defines the interval at which buffered entries are flushed, even if the batch size is not reached.
|
||||
// Example:
|
||||
//
|
||||
// handler := NewBuffered(textHandler, WithFlushInterval(5*time.Second)) // Flush every 5 seconds
|
||||
func WithFlushInterval(d time.Duration) BufferingOpt {
|
||||
return func(c *Buffering) {
|
||||
c.FlushInterval = d
|
||||
}
|
||||
}
|
||||
|
||||
// WithMaxBuffer sets the maximum buffer size before backpressure.
|
||||
// It limits the number of entries that can be queued in the channel, triggering overflow handling if exceeded.
|
||||
// Example:
|
||||
//
|
||||
// handler := NewBuffered(textHandler, WithMaxBuffer(500)) // Allow up to 500 buffered entries
|
||||
func WithMaxBuffer(size int) BufferingOpt {
|
||||
return func(c *Buffering) {
|
||||
c.MaxBuffer = size
|
||||
}
|
||||
}
|
||||
|
||||
// WithOverflowHandler sets the overflow callback.
|
||||
// It specifies a function to call when the buffer reaches MaxBuffer, typically for logging or metrics.
|
||||
// Example:
|
||||
//
|
||||
// handler := NewBuffered(textHandler, WithOverflowHandler(func(n int) { fmt.Printf("Overflow: %d entries\n", n) }))
|
||||
func WithOverflowHandler(fn func(int)) BufferingOpt {
|
||||
return func(c *Buffering) {
|
||||
c.OnOverflow = fn
|
||||
}
|
||||
}
|
||||
|
||||
// WithErrorOutput sets the destination for internal errors (e.g., downstream handler failures).
|
||||
// Defaults to os.Stderr if not set.
|
||||
// Example:
|
||||
//
|
||||
// // Redirect internal errors to a file or discard them
|
||||
// handler := NewBuffered(textHandler, WithErrorOutput(os.Stdout))
|
||||
func WithErrorOutput(w io.Writer) BufferingOpt {
|
||||
return func(c *Buffering) {
|
||||
c.ErrorOutput = w
|
||||
}
|
||||
}
|
||||
|
||||
// Buffered wraps any Handler to provide buffering capabilities.
|
||||
// It buffers log entries in a channel and flushes them based on batch size, time interval, or explicit flush.
|
||||
// The generic type H ensures compatibility with any lx.Handler implementation.
|
||||
// Thread-safe via channels and sync primitives.
|
||||
type Buffered[H lx.Handler] struct {
|
||||
handler H // Underlying handler to process log entries
|
||||
config *Buffering // Configuration for batching and flushing
|
||||
entries chan *lx.Entry // Channel for buffering log entries
|
||||
flushSignal chan struct{} // Channel to trigger explicit flushes
|
||||
shutdown chan struct{} // Channel to signal worker shutdown
|
||||
shutdownOnce sync.Once // Ensures Close is called only once
|
||||
wg sync.WaitGroup // Waits for worker goroutine to finish
|
||||
}
|
||||
|
||||
// NewBuffered creates a new buffered handler that wraps another handler.
|
||||
// It initializes the handler with default or provided configuration options and starts a worker goroutine.
|
||||
// Thread-safe via channel operations and finalizer for cleanup.
|
||||
// Example:
|
||||
//
|
||||
// textHandler := lh.NewTextHandler(os.Stdout)
|
||||
// buffered := NewBuffered(textHandler, WithBatchSize(50))
|
||||
func NewBuffered[H lx.Handler](handler H, opts ...BufferingOpt) *Buffered[H] {
|
||||
// Initialize default configuration
|
||||
config := &Buffering{
|
||||
BatchSize: 100, // Default: flush every 100 entries
|
||||
FlushInterval: 10 * time.Second, // Default: flush every 10 seconds
|
||||
MaxBuffer: 1000, // Default: max 1000 entries in buffer
|
||||
ErrorOutput: os.Stderr, // Default: report errors to stderr
|
||||
OnOverflow: func(count int) { // Default: log overflow to io.Discard (silent by default for overflow)
|
||||
fmt.Fprintf(io.Discard, "log buffer overflow: %d entries\n", count)
|
||||
},
|
||||
}
|
||||
|
||||
// Apply provided options
|
||||
for _, opt := range opts {
|
||||
opt(config)
|
||||
}
|
||||
|
||||
// Ensure sane configuration values
|
||||
if config.BatchSize < 1 {
|
||||
config.BatchSize = 1 // Minimum batch size is 1
|
||||
}
|
||||
if config.MaxBuffer < config.BatchSize {
|
||||
config.MaxBuffer = config.BatchSize * 10 // Ensure buffer is at least 10x batch size
|
||||
}
|
||||
if config.FlushInterval <= 0 {
|
||||
config.FlushInterval = 10 * time.Second // Minimum flush interval is 10s
|
||||
}
|
||||
if config.ErrorOutput == nil {
|
||||
config.ErrorOutput = os.Stderr
|
||||
}
|
||||
|
||||
// Initialize Buffered handler
|
||||
b := &Buffered[H]{
|
||||
handler: handler, // Set underlying handler
|
||||
config: config, // Set configuration
|
||||
entries: make(chan *lx.Entry, config.MaxBuffer), // Create buffered channel
|
||||
flushSignal: make(chan struct{}, 1), // Create single-slot flush signal channel
|
||||
shutdown: make(chan struct{}), // Create shutdown signal channel
|
||||
}
|
||||
|
||||
// Start worker goroutine
|
||||
b.wg.Add(1)
|
||||
go b.worker()
|
||||
|
||||
// Set finalizer for cleanup during garbage collection
|
||||
runtime.SetFinalizer(b, (*Buffered[H]).Final)
|
||||
return b
|
||||
}
|
||||
|
||||
// Handle implements the lx.Handler interface.
|
||||
// It buffers log entries in the entries channel or triggers a flush on overflow.
|
||||
// Returns an error if the buffer is full and flush cannot be triggered.
|
||||
// Thread-safe via non-blocking channel operations.
|
||||
// Example:
|
||||
//
|
||||
// buffered.Handle(&lx.Entry{Message: "test"}) // Buffers entry or triggers flush
|
||||
func (b *Buffered[H]) Handle(e *lx.Entry) error {
|
||||
select {
|
||||
case b.entries <- e: // Buffer entry if channel has space
|
||||
return nil
|
||||
default: // Handle buffer overflow
|
||||
if b.config.OnOverflow != nil {
|
||||
b.config.OnOverflow(len(b.entries)) // Call overflow handler
|
||||
}
|
||||
select {
|
||||
case b.flushSignal <- struct{}{}: // Trigger flush if possible
|
||||
return fmt.Errorf("log buffer overflow, triggering flush")
|
||||
default: // Flush already in progress
|
||||
return fmt.Errorf("log buffer overflow and flush already in progress")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush triggers an immediate flush of buffered entries.
|
||||
// It sends a signal to the worker to process all buffered entries.
|
||||
// If a flush is already pending, it waits briefly and may exit without flushing.
|
||||
// Thread-safe via non-blocking channel operations.
|
||||
// Example:
|
||||
//
|
||||
// buffered.Flush() // Flushes all buffered entries
|
||||
func (b *Buffered[H]) Flush() {
|
||||
select {
|
||||
case b.flushSignal <- struct{}{}: // Signal worker to flush
|
||||
case <-time.After(100 * time.Millisecond): // Timeout if flush is pending
|
||||
// Flush already pending
|
||||
}
|
||||
}
|
||||
|
||||
// Close flushes any remaining entries and stops the worker.
|
||||
// It ensures shutdown is performed only once and waits for the worker to finish.
|
||||
// If the underlying handler implements a Close() error method, it will be called to release resources.
|
||||
// Thread-safe via sync.Once and WaitGroup.
|
||||
// Returns any error from the underlying handler's Close, or nil.
|
||||
// Example:
|
||||
//
|
||||
// buffered.Close() // Flushes entries and stops worker
|
||||
func (b *Buffered[H]) Close() error {
|
||||
var closeErr error
|
||||
b.shutdownOnce.Do(func() {
|
||||
close(b.shutdown) // Signal worker to shut down
|
||||
b.wg.Wait() // Wait for worker to finish
|
||||
runtime.SetFinalizer(b, nil) // Remove finalizer
|
||||
|
||||
// Check if underlying handler has a Close method and call it
|
||||
if closer, ok := any(b.handler).(interface{ Close() error }); ok {
|
||||
closeErr = closer.Close()
|
||||
}
|
||||
})
|
||||
return closeErr
|
||||
}
|
||||
|
||||
// Final ensures remaining entries are flushed during garbage collection.
|
||||
// It calls Close to flush entries and stop the worker.
|
||||
// Used as a runtime finalizer to prevent log loss.
|
||||
// Example (internal usage):
|
||||
//
|
||||
// runtime.SetFinalizer(buffered, (*Buffered[H]).Final)
|
||||
func (b *Buffered[H]) Final() {
|
||||
b.Close()
|
||||
}
|
||||
|
||||
// Config returns the current configuration of the Buffered handler.
|
||||
// It provides access to BatchSize, FlushInterval, MaxBuffer, and OnOverflow settings.
|
||||
// Example:
|
||||
//
|
||||
// config := buffered.Config() // Access configuration
|
||||
func (b *Buffered[H]) Config() *Buffering {
|
||||
return b.config
|
||||
}
|
||||
|
||||
// worker processes entries and handles flushing.
|
||||
// It runs in a goroutine, buffering entries, flushing on batch size, timer, or explicit signal,
|
||||
// and shutting down cleanly when signaled.
|
||||
// Thread-safe via channel operations and WaitGroup.
|
||||
func (b *Buffered[H]) worker() {
|
||||
defer b.wg.Done() // Signal completion when worker exits
|
||||
batch := make([]*lx.Entry, 0, b.config.BatchSize) // Buffer for batching entries
|
||||
ticker := time.NewTicker(b.config.FlushInterval) // Timer for periodic flushes
|
||||
defer ticker.Stop() // Clean up ticker
|
||||
for {
|
||||
select {
|
||||
case entry := <-b.entries: // Receive new entry
|
||||
batch = append(batch, entry)
|
||||
// Flush if batch size is reached
|
||||
if len(batch) >= b.config.BatchSize {
|
||||
b.flushBatch(batch)
|
||||
batch = batch[:0]
|
||||
}
|
||||
case <-ticker.C: // Periodic flush
|
||||
if len(batch) > 0 {
|
||||
b.flushBatch(batch)
|
||||
batch = batch[:0]
|
||||
}
|
||||
case <-b.flushSignal: // Explicit flush
|
||||
if len(batch) > 0 {
|
||||
b.flushBatch(batch)
|
||||
batch = batch[:0]
|
||||
}
|
||||
b.drainRemaining() // Drain all entries from the channel
|
||||
case <-b.shutdown: // Shutdown signal
|
||||
if len(batch) > 0 {
|
||||
b.flushBatch(batch)
|
||||
}
|
||||
b.drainRemaining() // Flush remaining entries
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// flushBatch processes a batch of entries through the wrapped handler.
|
||||
// It writes each entry to the underlying handler, logging any errors to the configured ErrorOutput.
|
||||
// Example (internal usage):
|
||||
//
|
||||
// b.flushBatch([]*lx.Entry{entry1, entry2})
|
||||
func (b *Buffered[H]) flushBatch(batch []*lx.Entry) {
|
||||
for _, entry := range batch {
|
||||
// Process each entry through the handler
|
||||
if err := b.handler.Handle(entry); err != nil {
|
||||
if b.config.ErrorOutput != nil {
|
||||
fmt.Fprintf(b.config.ErrorOutput, "log flush error: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// drainRemaining processes any remaining entries in the channel.
|
||||
// It flushes all entries from the entries channel to the underlying handler,
|
||||
// logging any errors to the configured ErrorOutput. Used during flush or shutdown.
|
||||
// Example (internal usage):
|
||||
//
|
||||
// b.drainRemaining() // Flushes all pending entries
|
||||
func (b *Buffered[H]) drainRemaining() {
|
||||
for {
|
||||
select {
|
||||
case entry := <-b.entries: // Process next entry
|
||||
if err := b.handler.Handle(entry); err != nil {
|
||||
if b.config.ErrorOutput != nil {
|
||||
fmt.Fprintf(b.config.ErrorOutput, "log drain error: %v\n", err)
|
||||
}
|
||||
}
|
||||
default: // Exit when channel is empty
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
+1063
File diff suppressed because it is too large
Load Diff
+163
@@ -0,0 +1,163 @@
|
||||
package lh
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/olekukonko/ll/lx"
|
||||
)
|
||||
|
||||
// Dedup is a log handler that suppresses duplicate entries within a TTL window.
|
||||
// It wraps another handler (H) and filters out repeated log entries that match
|
||||
// within the deduplication period.
|
||||
type Dedup[H lx.Handler] struct {
|
||||
next H
|
||||
|
||||
ttl time.Duration
|
||||
cleanupEvery time.Duration
|
||||
keyFn func(*lx.Entry) uint64
|
||||
maxKeys int
|
||||
|
||||
// shards reduce lock contention by partitioning the key space
|
||||
shards [32]dedupShard
|
||||
|
||||
done chan struct{}
|
||||
wg sync.WaitGroup
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
type dedupShard struct {
|
||||
mu sync.Mutex
|
||||
seen map[uint64]int64
|
||||
}
|
||||
|
||||
// DedupOpt configures a Dedup handler.
|
||||
type DedupOpt[H lx.Handler] func(*Dedup[H])
|
||||
|
||||
// WithDedupKeyFunc customizes how deduplication keys are generated.
|
||||
func WithDedupKeyFunc[H lx.Handler](fn func(*lx.Entry) uint64) DedupOpt[H] {
|
||||
return func(d *Dedup[H]) { d.keyFn = fn }
|
||||
}
|
||||
|
||||
// WithDedupCleanupInterval sets how often expired deduplication keys are purged.
|
||||
func WithDedupCleanupInterval[H lx.Handler](every time.Duration) DedupOpt[H] {
|
||||
return func(d *Dedup[H]) {
|
||||
if every > 0 {
|
||||
d.cleanupEvery = every
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithDedupMaxKeys sets a soft limit on tracked deduplication keys.
|
||||
func WithDedupMaxKeys[H lx.Handler](max int) DedupOpt[H] {
|
||||
return func(d *Dedup[H]) {
|
||||
if max > 0 {
|
||||
d.maxKeys = max
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NewDedup creates a deduplicating handler wrapper.
|
||||
func NewDedup[H lx.Handler](next H, ttl time.Duration, opts ...DedupOpt[H]) *Dedup[H] {
|
||||
if ttl <= 0 {
|
||||
ttl = 2 * time.Second
|
||||
}
|
||||
|
||||
d := &Dedup[H]{
|
||||
next: next,
|
||||
ttl: ttl,
|
||||
cleanupEvery: time.Minute,
|
||||
keyFn: defaultDedupKey,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
// Initialize shards
|
||||
for i := 0; i < len(d.shards); i++ {
|
||||
d.shards[i].seen = make(map[uint64]int64, 64)
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(d)
|
||||
}
|
||||
|
||||
d.wg.Add(1)
|
||||
go d.cleanupLoop()
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
// Handle processes a log entry, suppressing duplicates within the TTL window.
|
||||
func (d *Dedup[H]) Handle(e *lx.Entry) error {
|
||||
now := time.Now().UnixNano()
|
||||
key := d.keyFn(e)
|
||||
|
||||
// Select shard based on key hash
|
||||
shardIdx := key % uint64(len(d.shards))
|
||||
shard := &d.shards[shardIdx]
|
||||
|
||||
shard.mu.Lock()
|
||||
exp, ok := shard.seen[key]
|
||||
if ok && now < exp {
|
||||
shard.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Basic guard against unbounded growth per shard
|
||||
// Using strict limits per shard avoids global atomic counters
|
||||
limitPerShard := d.maxKeys / len(d.shards)
|
||||
if d.maxKeys > 0 && len(shard.seen) >= limitPerShard {
|
||||
// Opportunistic cleanup of current shard
|
||||
d.cleanupShard(shard, now)
|
||||
}
|
||||
|
||||
shard.seen[key] = now + d.ttl.Nanoseconds()
|
||||
shard.mu.Unlock()
|
||||
|
||||
return d.next.Handle(e)
|
||||
}
|
||||
|
||||
// Close stops the cleanup goroutine and closes the underlying handler.
|
||||
func (d *Dedup[H]) Close() error {
|
||||
var err error
|
||||
d.once.Do(func() {
|
||||
close(d.done)
|
||||
d.wg.Wait()
|
||||
|
||||
if c, ok := any(d.next).(interface{ Close() error }); ok {
|
||||
err = c.Close()
|
||||
}
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// cleanupLoop runs periodically to purge expired deduplication keys.
|
||||
func (d *Dedup[H]) cleanupLoop() {
|
||||
defer d.wg.Done()
|
||||
|
||||
t := time.NewTicker(d.cleanupEvery)
|
||||
defer t.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-t.C:
|
||||
now := time.Now().UnixNano()
|
||||
// Cleanup all shards sequentially to avoid massive CPU spike
|
||||
for i := 0; i < len(d.shards); i++ {
|
||||
d.shards[i].mu.Lock()
|
||||
d.cleanupShard(&d.shards[i], now)
|
||||
d.shards[i].mu.Unlock()
|
||||
}
|
||||
case <-d.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupShard removes expired keys from a specific shard.
|
||||
func (d *Dedup[H]) cleanupShard(shard *dedupShard, now int64) {
|
||||
for k, exp := range shard.seen {
|
||||
if now > exp {
|
||||
delete(shard.seen, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
package lh
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/olekukonko/ll/lx"
|
||||
)
|
||||
|
||||
var jsonBufPool = sync.Pool{
|
||||
New: func() any {
|
||||
return new(bytes.Buffer)
|
||||
},
|
||||
}
|
||||
|
||||
// JSONHandler is a handler that outputs log entries as JSON objects.
|
||||
// It formats log entries with timestamp, level, message, namespace, fields, and optional
|
||||
// stack traces or dump segments, writing the result to the provided writer.
|
||||
// Thread-safe with a mutex to protect concurrent writes.
|
||||
type JSONHandler struct {
|
||||
writer io.Writer // Destination for JSON output
|
||||
timeFmt string // Format for timestamp (default: RFC3339Nano)
|
||||
pretty bool // Enable pretty printing with indentation if true
|
||||
//fieldMap map[string]string // Optional mapping for field names (not used in provided code)
|
||||
mu sync.Mutex // Protects concurrent access to writer
|
||||
}
|
||||
|
||||
// JsonOutput represents the JSON structure for a log entry.
|
||||
// It includes all relevant log data, such as timestamp, level, message, and optional
|
||||
// stack trace or dump segments, serialized as a JSON object.
|
||||
type JsonOutput struct {
|
||||
Time string `json:"ts"` // Timestamp in specified format
|
||||
Level string `json:"lvl"` // Log level (e.g., "INFO")
|
||||
Class string `json:"class"` // Entry class (e.g., "Text", "Dump")
|
||||
Msg string `json:"msg"` // Log message
|
||||
Namespace string `json:"ns"` // Namespace path
|
||||
Stack []byte `json:"stack"` // Stack trace (if present)
|
||||
Dump []dumpSegment `json:"dump"` // Hex/ASCII dump segments (for ClassDump)
|
||||
Fields map[string]interface{} `json:"fields"` // Custom fields
|
||||
}
|
||||
|
||||
// dumpSegment represents a single segment of a hex/ASCII dump.
|
||||
// Used for ClassDump entries to structure position, hex values, and ASCII representation.
|
||||
type dumpSegment struct {
|
||||
Offset int `json:"offset"` // Starting byte offset of the segment
|
||||
Hex []string `json:"hex"` // Hexadecimal values of bytes
|
||||
ASCII string `json:"ascii"` // ASCII representation of bytes
|
||||
}
|
||||
|
||||
// NewJSONHandler creates a new JSONHandler writing to the specified writer.
|
||||
// It initializes the handler with a default timestamp format (RFC3339Nano) and optional
|
||||
// configuration functions to customize settings like pretty printing.
|
||||
// Example:
|
||||
//
|
||||
// handler := NewJSONHandler(os.Stdout)
|
||||
// logger := ll.New("app").Enable().Handler(handler)
|
||||
// logger.Info("Test") // Output: {"ts":"...","lvl":"INFO","class":"Text","msg":"Test","ns":"app","stack":null,"dump":null,"fields":null}
|
||||
func NewJSONHandler(w io.Writer, opts ...func(*JSONHandler)) *JSONHandler {
|
||||
h := &JSONHandler{
|
||||
writer: w, // Set output writer
|
||||
timeFmt: time.RFC3339Nano, // Default timestamp format
|
||||
}
|
||||
// Apply configuration options
|
||||
for _, opt := range opts {
|
||||
opt(h)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// Handle processes a log entry and writes it as JSON.
|
||||
// It delegates to specialized methods based on the entry's class (Dump or regular),
|
||||
// ensuring thread-safety with a mutex.
|
||||
// Returns an error if JSON encoding or writing fails.
|
||||
// Example:
|
||||
//
|
||||
// handler.Handle(&lx.Entry{Message: "test", Level: lx.LevelInfo}) // Writes JSON object
|
||||
func (h *JSONHandler) Handle(e *lx.Entry) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
// Handle dump entries separately
|
||||
if e.Class == lx.ClassDump {
|
||||
return h.handleDump(e)
|
||||
}
|
||||
// Handle standard log entries
|
||||
return h.handleRegular(e)
|
||||
}
|
||||
|
||||
// Output sets the Writer destination for JSONHandler's output, ensuring thread safety with a mutex lock.
|
||||
func (h *JSONHandler) Output(w io.Writer) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.writer = w
|
||||
}
|
||||
|
||||
// handleRegular handles standard log entries (non-dump).
|
||||
// It converts the entry to a JsonOutput struct and encodes it as JSON,
|
||||
// applying pretty printing if enabled. Logs encoding errors to stderr for debugging.
|
||||
// Returns an error if encoding or writing fails.
|
||||
// Example (internal usage):
|
||||
//
|
||||
// h.handleRegular(&lx.Entry{Message: "test", Level: lx.LevelInfo}) // Writes JSON object
|
||||
func (h *JSONHandler) handleRegular(e *lx.Entry) error {
|
||||
// Convert ordered fields to map for JSON output
|
||||
fieldsMap := make(map[string]interface{}, len(e.Fields))
|
||||
for _, pair := range e.Fields {
|
||||
fieldsMap[pair.Key] = pair.Value
|
||||
}
|
||||
|
||||
// Create JSON output structure
|
||||
entry := JsonOutput{
|
||||
Time: e.Timestamp.Format(h.timeFmt), // Format timestamp
|
||||
Level: e.Level.String(), // Convert level to string
|
||||
Class: e.Class.String(), // Convert class to string
|
||||
Msg: e.Message, // Set message
|
||||
Namespace: e.Namespace, // Set namespace
|
||||
Dump: nil, // No dump for regular entries
|
||||
Fields: fieldsMap, // Copy fields as map
|
||||
Stack: e.Stack, // Include stack trace if present
|
||||
}
|
||||
|
||||
// Acquire buffer from pool to avoid allocation and reduce syscalls
|
||||
buf := jsonBufPool.Get().(*bytes.Buffer)
|
||||
buf.Reset()
|
||||
defer jsonBufPool.Put(buf)
|
||||
|
||||
// Create JSON encoder writing to buffer
|
||||
enc := json.NewEncoder(buf)
|
||||
if h.pretty {
|
||||
// Enable indentation for pretty printing
|
||||
enc.SetIndent("", " ")
|
||||
}
|
||||
|
||||
// Encode JSON to buffer
|
||||
err := enc.Encode(entry)
|
||||
if err != nil {
|
||||
// Log encoding error for debugging
|
||||
fmt.Fprintf(os.Stderr, "JSON encode error: %v\n", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Write buffer to underlying writer in one go
|
||||
_, err = h.writer.Write(buf.Bytes())
|
||||
return err
|
||||
}
|
||||
|
||||
// handleDump processes ClassDump entries, converting hex dump output to JSON segments.
|
||||
// It parses the dump message into structured segments with offset, hex, and ASCII data,
|
||||
// encoding them as a JsonOutput struct.
|
||||
// Returns an error if parsing or encoding fails.
|
||||
// Example (internal usage):
|
||||
//
|
||||
// h.handleDump(&lx.Entry{Class: lx.ClassDump, Message: "pos 00 hex: 61 62 'ab'"}) // Writes JSON with dump segments
|
||||
func (h *JSONHandler) handleDump(e *lx.Entry) error {
|
||||
var segments []dumpSegment
|
||||
lines := strings.Split(e.Message, "\n")
|
||||
|
||||
// Parse each line of the dump message
|
||||
for _, line := range lines {
|
||||
if !strings.HasPrefix(line, "pos") {
|
||||
continue // Skip non-dump lines
|
||||
}
|
||||
parts := strings.SplitN(line, "hex:", 2)
|
||||
if len(parts) != 2 {
|
||||
continue // Skip invalid lines
|
||||
}
|
||||
// Parse position
|
||||
var offset int
|
||||
fmt.Sscanf(parts[0], "pos %d", &offset)
|
||||
|
||||
// Parse hex and ASCII
|
||||
hexAscii := strings.SplitN(parts[1], "'", 2)
|
||||
hexStr := strings.Fields(strings.TrimSpace(hexAscii[0]))
|
||||
|
||||
// Create dump segment
|
||||
segments = append(segments, dumpSegment{
|
||||
Offset: offset, // Set byte offset
|
||||
Hex: hexStr, // Set hex values
|
||||
ASCII: strings.Trim(hexAscii[1], "'"), // Set ASCII representation
|
||||
})
|
||||
}
|
||||
|
||||
// Convert ordered fields to map for JSON output
|
||||
fieldsMap := make(map[string]interface{}, len(e.Fields))
|
||||
for _, pair := range e.Fields {
|
||||
fieldsMap[pair.Key] = pair.Value
|
||||
}
|
||||
|
||||
// Acquire buffer from pool
|
||||
buf := jsonBufPool.Get().(*bytes.Buffer)
|
||||
buf.Reset()
|
||||
defer jsonBufPool.Put(buf)
|
||||
|
||||
// Encode JSON output with dump segments to buffer
|
||||
enc := json.NewEncoder(buf)
|
||||
if h.pretty {
|
||||
enc.SetIndent("", " ")
|
||||
}
|
||||
|
||||
err := enc.Encode(JsonOutput{
|
||||
Time: e.Timestamp.Format(h.timeFmt), // Format timestamp
|
||||
Level: e.Level.String(), // Convert level to string
|
||||
Class: e.Class.String(), // Convert class to string
|
||||
Msg: "dumping segments", // Fixed message for dumps
|
||||
Namespace: e.Namespace, // Set namespace
|
||||
Dump: segments, // Include parsed segments
|
||||
Fields: fieldsMap, // Copy fields as map
|
||||
Stack: e.Stack, // Include stack trace if present
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "JSON dump encode error: %v\n", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Write buffer to underlying writer
|
||||
_, err = h.writer.Write(buf.Bytes())
|
||||
return err
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package lh
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/cespare/xxhash/v2"
|
||||
"github.com/olekukonko/ll/lx"
|
||||
)
|
||||
|
||||
// rightPad pads a string with spaces on the right to reach the specified length.
|
||||
// Returns the original string if it's already at or exceeds the target length.
|
||||
// Uses strings.Builder for efficient memory allocation.
|
||||
func rightPad(str string, length int) string {
|
||||
if len(str) >= length {
|
||||
return str
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.Grow(length)
|
||||
sb.WriteString(str)
|
||||
sb.WriteString(strings.Repeat(" ", length-len(str)))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
var dedupBufPool = sync.Pool{
|
||||
New: func() any { return new(bytes.Buffer) },
|
||||
}
|
||||
|
||||
// defaultDedupKey generates a deduplication key from log level and message.
|
||||
// Uses FNV-1a hash for speed and good distribution. Override with WithDedupKeyFunc
|
||||
// to include additional fields like namespace, caller, or structured fields.
|
||||
func defaultDedupKey(e *lx.Entry) uint64 {
|
||||
h := xxhash.New()
|
||||
|
||||
_, _ = h.Write([]byte(e.Level.String()))
|
||||
_, _ = h.Write([]byte{0})
|
||||
_, _ = h.Write([]byte(e.Message))
|
||||
_, _ = h.Write([]byte{0})
|
||||
_, _ = h.Write([]byte(e.Namespace))
|
||||
_, _ = h.Write([]byte{0})
|
||||
|
||||
if len(e.Fields) > 0 {
|
||||
m := e.Fields.Map()
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
buf := dedupBufPool.Get().(*bytes.Buffer)
|
||||
buf.Reset()
|
||||
defer dedupBufPool.Put(buf)
|
||||
|
||||
for _, k := range keys {
|
||||
fmt.Fprint(buf, k)
|
||||
buf.WriteByte('=')
|
||||
fmt.Fprint(buf, m[k])
|
||||
buf.WriteByte(0)
|
||||
}
|
||||
_, _ = h.Write(buf.Bytes())
|
||||
}
|
||||
|
||||
return h.Sum64()
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
package lh
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
|
||||
"github.com/olekukonko/ll/lx"
|
||||
)
|
||||
|
||||
// MemoryHandler is an lx.Handler that stores log entries in memory.
|
||||
// Useful for testing or buffering logs for later inspection.
|
||||
// It maintains a thread-safe slice of log entries, protected by a read-write mutex.
|
||||
type MemoryHandler struct {
|
||||
mu sync.RWMutex // Protects concurrent access to entries
|
||||
entries []*lx.Entry // Slice of stored log entries
|
||||
showTime bool // Whether to show timestamps when dumping
|
||||
timeFormat string // Time format for dumping
|
||||
}
|
||||
|
||||
// NewMemoryHandler creates a new MemoryHandler.
|
||||
// It initializes an empty slice for storing log entries, ready for use in logging or testing.
|
||||
// Example:
|
||||
//
|
||||
// handler := NewMemoryHandler()
|
||||
// logger := ll.New("app").Enable().Handler(handler)
|
||||
// logger.Info("Test") // Stores entry in memory
|
||||
func NewMemoryHandler() *MemoryHandler {
|
||||
return &MemoryHandler{
|
||||
entries: make([]*lx.Entry, 0), // Initialize empty slice for entries
|
||||
}
|
||||
}
|
||||
|
||||
// Timestamped enables/disables timestamp display when dumping and optionally sets a time format.
|
||||
// Consistent with TextHandler and ColorizedHandler signature.
|
||||
// Example:
|
||||
//
|
||||
// handler.Timestamped(true) // Enable with default format
|
||||
// handler.Timestamped(true, time.StampMilli) // Enable with custom format
|
||||
// handler.Timestamped(false) // Disable
|
||||
func (h *MemoryHandler) Timestamped(enable bool, format ...string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
h.showTime = enable
|
||||
if len(format) > 0 && format[0] != "" {
|
||||
h.timeFormat = format[0]
|
||||
}
|
||||
}
|
||||
|
||||
// Handle stores the log entry in memory.
|
||||
// It appends the provided entry to the entries slice, ensuring thread-safety with a write lock.
|
||||
// Always returns nil, as it does not perform I/O operations.
|
||||
// Example:
|
||||
//
|
||||
// handler.Handle(&lx.Entry{Message: "test", Level: lx.LevelInfo}) // Stores entry
|
||||
func (h *MemoryHandler) Handle(entry *lx.Entry) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.entries = append(h.entries, entry) // Append entry to slice
|
||||
return nil
|
||||
}
|
||||
|
||||
// Entries returns a copy of the stored log entries.
|
||||
// It creates a new slice with copies of all entries, ensuring thread-safety with a read lock.
|
||||
// The returned slice is safe for external use without affecting the handler's internal state.
|
||||
// Example:
|
||||
//
|
||||
// entries := handler.Entries() // Returns copy of stored entries
|
||||
func (h *MemoryHandler) Entries() []*lx.Entry {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
entries := make([]*lx.Entry, len(h.entries)) // Create new slice for copy
|
||||
copy(entries, h.entries) // Copy entries to new slice
|
||||
return entries
|
||||
}
|
||||
|
||||
// Reset clears all stored entries.
|
||||
// It truncates the entries slice to zero length, preserving capacity, using a write lock for thread-safety.
|
||||
// Example:
|
||||
//
|
||||
// handler.Reset() // Clears all stored entries
|
||||
func (h *MemoryHandler) Reset() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.entries = h.entries[:0] // Truncate slice to zero length
|
||||
}
|
||||
|
||||
// Dump writes all stored log entries to the provided io.Writer in text format.
|
||||
// Entries are formatted as they would be by a TextHandler, including namespace, level,
|
||||
// message, and fields. Thread-safe with read lock.
|
||||
// Returns an error if writing fails.
|
||||
// Example:
|
||||
//
|
||||
// logger := ll.New("test", ll.WithHandler(NewMemoryHandler())).Enable()
|
||||
// logger.Info("Test message")
|
||||
// handler := logger.handler.(*MemoryHandler)
|
||||
// handler.Dump(os.Stdout) // Output: [test] INFO: Test message
|
||||
func (h *MemoryHandler) Dump(w io.Writer) error {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
// Create a temporary TextHandler to format entries
|
||||
tempHandler := NewTextHandler(w)
|
||||
tempHandler.Timestamped(h.showTime, h.timeFormat)
|
||||
|
||||
// Process each entry through the TextHandler
|
||||
for _, entry := range h.entries {
|
||||
if err := tempHandler.Handle(entry); err != nil {
|
||||
return fmt.Errorf("failed to dump entry: %writer", err) // Wrap and return write errors
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package lh
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/olekukonko/ll/lx"
|
||||
)
|
||||
|
||||
// MultiHandler combines multiple handlers to process log entries concurrently.
|
||||
// It holds a list of lx.Handler instances and delegates each log entry to all handlers,
|
||||
// collecting any errors into a single combined error.
|
||||
// Thread-safe if the underlying handlers are thread-safe.
|
||||
type MultiHandler struct {
|
||||
Handlers []lx.Handler // List of handlers to process each log entry
|
||||
}
|
||||
|
||||
// NewMultiHandler creates a new MultiHandler with the specified handlers.
|
||||
// It accepts a variadic list of handlers to be executed in order.
|
||||
// The returned handler processes log entries by passing them to each handler in sequence.
|
||||
// Example:
|
||||
//
|
||||
// textHandler := NewTextHandler(os.Stdout)
|
||||
// jsonHandler := NewJSONHandler(os.Stdout)
|
||||
// multi := NewMultiHandler(textHandler, jsonHandler)
|
||||
// logger := ll.New("app").Enable().Handler(multi)
|
||||
// logger.Info("Test") // Processed by both text and JSON handlers
|
||||
func NewMultiHandler(h ...lx.Handler) *MultiHandler {
|
||||
return &MultiHandler{
|
||||
Handlers: h, // Initialize with provided handlers
|
||||
}
|
||||
}
|
||||
|
||||
// Len returns the number of handlers in the MultiHandler.
|
||||
// Useful for monitoring or debugging handler composition.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// multi := &MultiHandler{}
|
||||
// multi.Append(h1, h2, h3)
|
||||
// count := multi.Len() // Returns 3
|
||||
func (h *MultiHandler) Len() int {
|
||||
return len(h.Handlers)
|
||||
}
|
||||
|
||||
// Append adds one or more handlers to the MultiHandler.
|
||||
// Handlers will receive log entries in the order they were appended.
|
||||
// This method modifies the MultiHandler in place.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// multi := &MultiHandler{}
|
||||
// multi.Append(
|
||||
// lx.NewJSONHandler(os.Stdout),
|
||||
// lx.NewTextHandler(logFile),
|
||||
// )
|
||||
// // Now multi broadcasts to both stdout and file
|
||||
func (h *MultiHandler) Append(handlers ...lx.Handler) {
|
||||
h.Handlers = append(h.Handlers, handlers...)
|
||||
}
|
||||
|
||||
// Handle implements the Handler interface, calling Handle on each handler in sequence.
|
||||
// It collects any errors from handlers and combines them into a single error using errors.Join.
|
||||
// If no errors occur, it returns nil. Thread-safe if the underlying handlers are thread-safe.
|
||||
// Example:
|
||||
//
|
||||
// multi.Handle(&lx.Entry{Message: "test", Level: lx.LevelInfo}) // Calls Handle on all handlers
|
||||
func (h *MultiHandler) Handle(e *lx.Entry) error {
|
||||
var errs []error // Collect errors from handlers
|
||||
for i, handler := range h.Handlers {
|
||||
// Process entry with each handler
|
||||
if err := handler.Handle(e); err != nil {
|
||||
// fmt.Fprintf(os.Stderr, "MultiHandler error for handler %d: %v\n", i, err)
|
||||
// Wrap error with handler index for context
|
||||
errs = append(errs, fmt.Errorf("handler %d: %writer", i, err))
|
||||
}
|
||||
}
|
||||
// Combine errors into a single error, or return nil if no errors
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package lh
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/olekukonko/ll/lx"
|
||||
)
|
||||
|
||||
// Pipe chains multiple handler wrappers together, applying them from left to right.
|
||||
// The wrappers are composed such that the first wrapper in the list becomes
|
||||
// the innermost layer, and the last wrapper becomes the outermost layer.
|
||||
//
|
||||
// Usage pattern: Pipe(baseHandler, wrapper1, wrapper2, wrapper3)
|
||||
// Result: wrapper3(wrapper2(wrapper1(baseHandler)))
|
||||
//
|
||||
// This enables clean, declarative construction of handler middleware chains.
|
||||
//
|
||||
// Example - building a processing pipeline:
|
||||
//
|
||||
// base := lx.NewJSONHandler(os.Stdout)
|
||||
// handler := lh.Pipe(base,
|
||||
// lh.NewDedup(2*time.Second), // 1. Deduplicate first
|
||||
// lh.NewRateLimit(10, time.Second), // 2. Then rate limit
|
||||
// )
|
||||
// logger := lx.NewLogger(handler)
|
||||
//
|
||||
// In this example, logs flow: Dedup → RateLimit → AddTimestamp → JSONHandler
|
||||
func Pipe(h lx.Handler, wraps ...lx.Wrap) lx.Handler {
|
||||
for _, w := range wraps {
|
||||
if w != nil {
|
||||
h = w(h)
|
||||
}
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// PipeDedup returns a wrapper that applies deduplication to the handler.
|
||||
func PipeDedup(ttl time.Duration, opts ...DedupOpt[lx.Handler]) lx.Wrap {
|
||||
return func(next lx.Handler) lx.Handler {
|
||||
return NewDedup(next, ttl, opts...)
|
||||
}
|
||||
}
|
||||
|
||||
// PipeBuffer returns a wrapper that applies buffering to the handler.
|
||||
func PipeBuffer(opts ...BufferingOpt) lx.Wrap {
|
||||
return func(next lx.Handler) lx.Handler {
|
||||
return NewBuffered(next, opts...)
|
||||
}
|
||||
}
|
||||
|
||||
// PipeRotate returns a wrapper that applies log rotation.
|
||||
// Ideally, the 'next' handler should be one that writes to a file (like TextHandler or JSONHandler).
|
||||
//
|
||||
// If the underlying handler does not implement lx.HandlerOutputter (cannot change output destination),
|
||||
// or if rotation initialization fails, this will log a warning to stderr and return the
|
||||
// original handler unmodified to prevent application crashes.
|
||||
func PipeRotate(maxSizeBytes int64, src RotateSource) lx.Wrap {
|
||||
return func(next lx.Handler) lx.Handler {
|
||||
// Attempt to cast to HandlerOutputter (Handler + Outputter interface)
|
||||
h, ok := next.(lx.HandlerOutputter)
|
||||
if !ok {
|
||||
fmt.Fprintf(os.Stderr, "ll/lh: PipeRotate skipped - handler does not implement SetOutput(io.Writer)\n")
|
||||
return next
|
||||
}
|
||||
|
||||
// Initialize the rotating handler
|
||||
r, err := NewRotating(h, maxSizeBytes, src)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "ll/lh: PipeRotate initialization failed: %v\n", err)
|
||||
return next
|
||||
}
|
||||
return r
|
||||
}
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
package lh
|
||||
|
||||
import (
|
||||
"io"
|
||||
"sync"
|
||||
|
||||
"github.com/olekukonko/ll/lx"
|
||||
)
|
||||
|
||||
// RotateSource defines the callbacks needed to implement log rotation.
|
||||
// It abstracts the destination lifecycle: opening, sizing, and rotating.
|
||||
//
|
||||
// Example for file rotation:
|
||||
//
|
||||
// src := lh.RotateSource{
|
||||
// Open: func() (io.WriteCloser, error) {
|
||||
// return os.OpenFile("app.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
// },
|
||||
// Size: func() (int64, error) {
|
||||
// if fi, err := os.Stat("app.log"); err == nil {
|
||||
// return fi.Size(), nil
|
||||
// }
|
||||
// return 0, nil // File doesn't exist yet
|
||||
// },
|
||||
// Rotate: func() error {
|
||||
// // Rename current log before creating new one
|
||||
// return os.Rename("app.log", "app.log."+time.Now().Format("20060102-150405"))
|
||||
// },
|
||||
// }
|
||||
type RotateSource struct {
|
||||
// Open returns a fresh destination for log output.
|
||||
// Called on initialization and after rotation.
|
||||
Open func() (io.WriteCloser, error)
|
||||
|
||||
// Size returns the current size in bytes of the active destination.
|
||||
// Return an error if size cannot be determined (rotation will be skipped).
|
||||
Size func() (int64, error)
|
||||
|
||||
// Rotate performs cleanup/rotation actions before opening a new destination.
|
||||
// For files: rename or move the current log. Optional for other destinations.
|
||||
Rotate func() error
|
||||
}
|
||||
|
||||
// Rotating wraps a handler to rotate its output when maxSize is exceeded.
|
||||
// The wrapped handler must implement both Handler and Outputter interfaces.
|
||||
// Rotation is triggered on each Handle call if the current size >= maxSize.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// handler := lx.NewJSONHandler(os.Stdout)
|
||||
// src := lh.RotateSource{...} // see RotateSource example
|
||||
// rotator, err := lh.NewRotating(handler, 10*1024*1024, src) // 10 MB
|
||||
// logger := lx.NewLogger(rotator)
|
||||
// logger.Info("This log may trigger rotation when file reaches 10MB")
|
||||
type Rotating[H interface {
|
||||
lx.Handler
|
||||
lx.Outputter
|
||||
}] struct {
|
||||
mu sync.Mutex
|
||||
maxSize int64
|
||||
src RotateSource
|
||||
|
||||
out io.WriteCloser
|
||||
handler H
|
||||
}
|
||||
|
||||
// NewRotating creates a rotating wrapper around handler.
|
||||
// Handler's output will be replaced with destinations from src.Open.
|
||||
// If maxSizeBytes <= 0, rotation is disabled.
|
||||
// src.Rotate may be nil if no pre-open actions are needed.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// // Create a JSON handler that rotates at 5MB
|
||||
// handler := lx.NewJSONHandler(os.Stdout)
|
||||
// rotator, err := lh.NewRotating(handler, 5*1024*1024, src)
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// // Use rotator as your logger's handler
|
||||
// logger := lx.NewLogger(rotator)
|
||||
func NewRotating[H interface {
|
||||
lx.Handler
|
||||
lx.Outputter
|
||||
}](handler H, maxSizeBytes int64, src RotateSource) (*Rotating[H], error) {
|
||||
r := &Rotating[H]{
|
||||
maxSize: maxSizeBytes,
|
||||
src: src,
|
||||
handler: handler,
|
||||
}
|
||||
if err := r.reopenLocked(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Handle processes a log entry, rotating output if necessary.
|
||||
// Thread-safe: can be called concurrently.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// rotator.Handle(&lx.Entry{
|
||||
// Level: lx.InfoLevel,
|
||||
// Message: "Processing request",
|
||||
// Namespace: "api",
|
||||
// })
|
||||
func (r *Rotating[H]) Handle(e *lx.Entry) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if err := r.rotateIfNeededLocked(); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.handler.Handle(e)
|
||||
}
|
||||
|
||||
// Close releases resources (closes the current output).
|
||||
// Safe to call multiple times.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// defer rotator.Close()
|
||||
func (r *Rotating[H]) Close() error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if r.out != nil {
|
||||
return r.out.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rotateIfNeededLocked checks current size and rotates if maxSize exceeded.
|
||||
// Called with mu already held.
|
||||
func (r *Rotating[H]) rotateIfNeededLocked() error {
|
||||
if r.maxSize <= 0 || r.src.Size == nil || r.src.Open == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
size, err := r.src.Size()
|
||||
if err != nil {
|
||||
// Size unknown - skip rotation
|
||||
return nil
|
||||
}
|
||||
if size < r.maxSize {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close current output
|
||||
if r.out != nil {
|
||||
_ = r.out.Close()
|
||||
r.out = nil
|
||||
}
|
||||
|
||||
// Run rotation hook (rename/move/commit)
|
||||
if r.src.Rotate != nil {
|
||||
if err := r.src.Rotate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Open fresh output
|
||||
return r.reopenLocked()
|
||||
}
|
||||
|
||||
// reopenLocked opens a new destination and sets it on the handler.
|
||||
// Called with mu already held.
|
||||
func (r *Rotating[H]) reopenLocked() error {
|
||||
out, err := r.src.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.out = out
|
||||
r.handler.Output(out)
|
||||
return nil
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package lh
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/olekukonko/ll/lx"
|
||||
)
|
||||
|
||||
// SlogHandler adapts a slog.Handler to implement lx.Handler.
|
||||
// It converts lx.Entry objects to slog.Record objects and delegates to an underlying
|
||||
// slog.Handler for processing, enabling compatibility with Go's standard slog package.
|
||||
// Thread-safe if the underlying slog.Handler is thread-safe.
|
||||
type SlogHandler struct {
|
||||
slogHandler slog.Handler // Underlying slog.Handler for processing log records
|
||||
}
|
||||
|
||||
// NewSlogHandler creates a new SlogHandler wrapping the provided slog.Handler.
|
||||
// It initializes the handler with the given slog.Handler, allowing lx.Entry logs to be
|
||||
// processed by slog's logging infrastructure.
|
||||
// Example:
|
||||
//
|
||||
// slogText := slog.NewTextHandler(os.Stdout, nil)
|
||||
// handler := NewSlogHandler(slogText)
|
||||
// logger := ll.New("app").Enable().Handler(handler)
|
||||
// logger.Info("Test") // Output: level=INFO msg=Test namespace=app class=Text
|
||||
func NewSlogHandler(h slog.Handler) *SlogHandler {
|
||||
return &SlogHandler{slogHandler: h}
|
||||
}
|
||||
|
||||
// Handle converts an lx.Entry to slog.Record and delegates to the slog.Handler.
|
||||
// It maps the entry's fields, level, namespace, class, and stack trace to slog attributes,
|
||||
// passing the resulting record to the underlying slog.Handler.
|
||||
// Returns an error if the slog.Handler fails to process the record.
|
||||
// Thread-safe if the underlying slog.Handler is thread-safe.
|
||||
// Example:
|
||||
//
|
||||
// handler.Handle(&lx.Entry{Message: "test", Level: lx.LevelInfo}) // Processes as slog record
|
||||
//
|
||||
// Handle converts an lx.Entry to slog.Record and delegates to the slog.Handler.
|
||||
// It maps the entry's fields, level, namespace, class, and stack trace to slog attributes,
|
||||
// passing the resulting record to the underlying slog.Handler.
|
||||
// Returns an error if the slog.Handler fails to process the record.
|
||||
// Thread-safe if the underlying slog.Handler is thread-safe.
|
||||
// Example:
|
||||
//
|
||||
// handler.Handle(&lx.Entry{Message: "test", Level: lx.LevelInfo}) // Processes as slog record
|
||||
func (h *SlogHandler) Handle(e *lx.Entry) error {
|
||||
// Convert lx.LevelType to slog.Level
|
||||
level := toSlogLevel(e.Level)
|
||||
|
||||
// Create a slog.Record with the entry's data
|
||||
record := slog.NewRecord(
|
||||
e.Timestamp, // time.Time for log timestamp
|
||||
level, // slog.Level for log severity
|
||||
e.Message, // string for log message
|
||||
0, // pc (program counter, optional, not used)
|
||||
)
|
||||
|
||||
// Add standard fields as attributes
|
||||
record.AddAttrs(
|
||||
slog.String("namespace", e.Namespace), // Add namespace as string attribute
|
||||
slog.String("class", e.Class.String()), // Add class as string attribute
|
||||
)
|
||||
|
||||
// Add stack trace if present
|
||||
if len(e.Stack) > 0 {
|
||||
record.AddAttrs(slog.String("stack", string(e.Stack))) // Add stack trace as string
|
||||
}
|
||||
|
||||
// Add custom fields in order (preserving insertion order)
|
||||
for _, pair := range e.Fields {
|
||||
record.AddAttrs(slog.Any(pair.Key, pair.Value)) // Add each field as a key-value attribute
|
||||
}
|
||||
|
||||
// Handle the record with the underlying slog.Handler
|
||||
return h.slogHandler.Handle(context.Background(), record)
|
||||
}
|
||||
|
||||
// toSlogLevel converts lx.LevelType to slog.Level.
|
||||
// It maps the logging levels used by the lx package to those used by slog,
|
||||
// defaulting to slog.LevelInfo for unknown levels.
|
||||
// Example (internal usage):
|
||||
//
|
||||
// level := toSlogLevel(lx.LevelDebug) // Returns slog.LevelDebug
|
||||
func toSlogLevel(level lx.LevelType) slog.Level {
|
||||
switch level {
|
||||
case lx.LevelDebug:
|
||||
return slog.LevelDebug
|
||||
case lx.LevelInfo:
|
||||
return slog.LevelInfo
|
||||
case lx.LevelWarn:
|
||||
return slog.LevelWarn
|
||||
case lx.LevelError, lx.LevelFatal:
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo // Default for unknown levels
|
||||
}
|
||||
}
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
package lh
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/olekukonko/ll/lx"
|
||||
)
|
||||
|
||||
type TextOption func(*TextHandler)
|
||||
|
||||
var textBufPool = sync.Pool{
|
||||
New: func() any {
|
||||
return new(bytes.Buffer)
|
||||
},
|
||||
}
|
||||
|
||||
// WithTextTimeFormat enables timestamp display and optionally sets a custom time format.
|
||||
// It configures the TextHandler to include temporal information in each log entry,
|
||||
// allowing for precise tracking of when log events occur.
|
||||
// If the format string is empty, it defaults to time.RFC3339.
|
||||
func WithTextTimeFormat(format string) TextOption {
|
||||
return func(t *TextHandler) {
|
||||
t.Timestamped(true, format)
|
||||
}
|
||||
}
|
||||
|
||||
// WithTextShowTime enables or disables timestamp display in log entries.
|
||||
// This option provides direct control over the visibility of the time prefix
|
||||
// without altering the underlying time format configured in the handler.
|
||||
// Setting show to true will prepend timestamps to all subsequent regular log outputs.
|
||||
func WithTextShowTime(show bool) TextOption {
|
||||
return func(t *TextHandler) {
|
||||
t.showTime = show
|
||||
}
|
||||
}
|
||||
|
||||
// TextHandler is a handler that outputs log entries as plain text.
|
||||
// It formats log entries with namespace, level, message, fields, and optional stack traces,
|
||||
// writing the result to the provided writer.
|
||||
// Thread-safe if the underlying writer is thread-safe.
|
||||
type TextHandler struct {
|
||||
writer io.Writer // Destination for formatted log output
|
||||
showTime bool // Whether to display timestamps
|
||||
timeFormat string // Format for timestamps (defaults to time.RFC3339)
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewTextHandler creates a new TextHandler writing to the specified writer.
|
||||
// It initializes the handler with the given writer, suitable for outputs like stdout or files.
|
||||
// Example:
|
||||
//
|
||||
// handler := NewTextHandler(os.Stdout)
|
||||
// logger := ll.New("app").Enable().Handler(handler)
|
||||
// logger.Info("Test") // Output: [app] INFO: Test
|
||||
func NewTextHandler(w io.Writer, opts ...TextOption) *TextHandler {
|
||||
t := &TextHandler{
|
||||
writer: w,
|
||||
showTime: false,
|
||||
timeFormat: time.RFC3339,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(t)
|
||||
}
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
// Timestamped enables or disables timestamp display and optionally sets a custom time format.
|
||||
// If format is empty, defaults to RFC3339.
|
||||
// Example:
|
||||
//
|
||||
// handler := NewTextHandler(os.Stdout).TextWithTime(true, time.StampMilli)
|
||||
// // Output: Jan 02 15:04:05.000 [app] INFO: Test
|
||||
func (h *TextHandler) Timestamped(enable bool, format ...string) {
|
||||
h.showTime = enable
|
||||
if len(format) > 0 && format[0] != "" {
|
||||
h.timeFormat = format[0]
|
||||
}
|
||||
}
|
||||
|
||||
// Output sets a new writer for the TextHandler.
|
||||
// Thread-safe - safe for concurrent use.
|
||||
func (h *TextHandler) Output(w io.Writer) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.writer = w
|
||||
}
|
||||
|
||||
// Handle processes a log entry and writes it as plain text.
|
||||
// It delegates to specialized methods based on the entry's class (Dump, Raw, or regular).
|
||||
// Returns an error if writing to the underlying writer fails.
|
||||
// Thread-safe if the writer is thread-safe.
|
||||
// Example:
|
||||
//
|
||||
// handler.Handle(&lx.Entry{Message: "test", Level: lx.LevelInfo}) // Writes "INFO: test"
|
||||
func (h *TextHandler) Handle(e *lx.Entry) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if e.Class == lx.ClassDump {
|
||||
return h.handleDumpOutput(e)
|
||||
}
|
||||
|
||||
if e.Class == lx.ClassRaw {
|
||||
_, err := h.writer.Write([]byte(e.Message))
|
||||
return err
|
||||
}
|
||||
|
||||
return h.handleRegularOutput(e)
|
||||
}
|
||||
|
||||
// handleRegularOutput handles normal log entries.
|
||||
// It formats the entry with namespace, level, message, fields, and stack trace (if present),
|
||||
// writing the result to the handler's writer.
|
||||
// Returns an error if writing fails.
|
||||
// Example (internal usage):
|
||||
//
|
||||
// h.handleRegularOutput(&lx.Entry{Message: "test", Level: lx.LevelInfo}) // Writes "INFO: test"
|
||||
func (h *TextHandler) handleRegularOutput(e *lx.Entry) error {
|
||||
buf := textBufPool.Get().(*bytes.Buffer)
|
||||
buf.Reset()
|
||||
defer textBufPool.Put(buf)
|
||||
|
||||
if h.showTime {
|
||||
buf.WriteString(e.Timestamp.Format(h.timeFormat))
|
||||
buf.WriteString(lx.Space)
|
||||
}
|
||||
|
||||
switch e.Style {
|
||||
case lx.NestedPath:
|
||||
if e.Namespace != "" {
|
||||
parts := strings.Split(e.Namespace, lx.Slash)
|
||||
for i, part := range parts {
|
||||
buf.WriteString(lx.LeftBracket)
|
||||
buf.WriteString(part)
|
||||
buf.WriteString(lx.RightBracket)
|
||||
if i < len(parts)-1 {
|
||||
buf.WriteString(lx.Arrow)
|
||||
}
|
||||
}
|
||||
buf.WriteString(lx.Colon)
|
||||
buf.WriteString(lx.Space)
|
||||
}
|
||||
default: // FlatPath
|
||||
if e.Namespace != "" {
|
||||
buf.WriteString(lx.LeftBracket)
|
||||
buf.WriteString(e.Namespace)
|
||||
buf.WriteString(lx.RightBracket)
|
||||
buf.WriteString(lx.Space)
|
||||
}
|
||||
}
|
||||
|
||||
buf.WriteString(e.Level.Name(e.Class))
|
||||
// buf.WriteString(lx.Space)
|
||||
buf.WriteString(lx.Colon)
|
||||
buf.WriteString(lx.Space)
|
||||
buf.WriteString(e.Message)
|
||||
|
||||
if len(e.Fields) > 0 {
|
||||
buf.WriteString(lx.Space)
|
||||
buf.WriteString(lx.LeftBracket)
|
||||
for i, pair := range e.Fields {
|
||||
if i > 0 {
|
||||
buf.WriteString(lx.Space)
|
||||
}
|
||||
buf.WriteString(pair.Key)
|
||||
buf.WriteString("=")
|
||||
fmt.Fprint(buf, pair.Value)
|
||||
}
|
||||
buf.WriteString(lx.RightBracket)
|
||||
}
|
||||
|
||||
if len(e.Stack) > 0 {
|
||||
h.formatStack(buf, e.Stack)
|
||||
}
|
||||
|
||||
if e.Level != lx.LevelNone {
|
||||
buf.WriteString(lx.Newline)
|
||||
}
|
||||
|
||||
_, err := h.writer.Write(buf.Bytes())
|
||||
return err
|
||||
}
|
||||
|
||||
// handleDumpOutput specially formats hex dump output (plain text version).
|
||||
// It wraps the dump message with BEGIN/END separators for clarity.
|
||||
// Returns an error if writing fails.
|
||||
// Example (internal usage):
|
||||
//
|
||||
// h.handleDumpOutput(&lx.Entry{Class: lx.ClassDump, Message: "pos 00 hex: 61"}) // Writes "---- BEGIN DUMP ----\npos 00 hex: 61\n---- END DUMP ----\n"
|
||||
func (h *TextHandler) handleDumpOutput(e *lx.Entry) error {
|
||||
buf := textBufPool.Get().(*bytes.Buffer)
|
||||
buf.Reset()
|
||||
defer textBufPool.Put(buf)
|
||||
|
||||
if h.showTime {
|
||||
buf.WriteString(e.Timestamp.Format(h.timeFormat))
|
||||
buf.WriteString(lx.Newline)
|
||||
}
|
||||
|
||||
buf.WriteString("---- BEGIN DUMP ----\n")
|
||||
buf.WriteString(e.Message)
|
||||
buf.WriteString("---- END DUMP ----\n\n")
|
||||
|
||||
_, err := h.writer.Write(buf.Bytes())
|
||||
return err
|
||||
}
|
||||
|
||||
// formatStack formats a stack trace for plain text output.
|
||||
// It structures the stack trace with indentation and separators for readability,
|
||||
// including goroutine and function/file details.
|
||||
// Example (internal usage):
|
||||
//
|
||||
// h.formatStack(&builder, []byte("goroutine 1 [running]:\nmain.main()\n\tmain.go:10")) // Appends formatted stack trace
|
||||
func (h *TextHandler) formatStack(b *bytes.Buffer, stack []byte) {
|
||||
lines := strings.Split(string(stack), "\n")
|
||||
if len(lines) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
b.WriteString("\n[stack]\n")
|
||||
|
||||
b.WriteString(" ┌─ ")
|
||||
b.WriteString(lines[0])
|
||||
b.WriteString("\n")
|
||||
|
||||
for i := 1; i < len(lines); i++ {
|
||||
line := strings.TrimSpace(lines[i])
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.Contains(line, ".go") {
|
||||
b.WriteString(" ├ ")
|
||||
} else {
|
||||
b.WriteString(" │ ")
|
||||
}
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
b.WriteString(" └\n")
|
||||
}
|
||||
+1457
File diff suppressed because it is too large
Load Diff
+140
@@ -0,0 +1,140 @@
|
||||
package lx
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Field represents a key-value pair where the key is a string and the value is of any type.
|
||||
type Field struct {
|
||||
Key string
|
||||
Value interface{}
|
||||
}
|
||||
|
||||
// Fields represents a slice of key-value pairs.
|
||||
type Fields []Field
|
||||
|
||||
// Map converts the Fields slice to a map[string]interface{}.
|
||||
// This is useful for backward compatibility or when map operations are needed.
|
||||
// Example:
|
||||
//
|
||||
// fields := lx.Fields{{"user", "alice"}, {"age", 30}}
|
||||
// m := fields.Map() // Returns map[string]interface{}{"user": "alice", "age": 30}
|
||||
func (f Fields) Map() map[string]interface{} {
|
||||
m := make(map[string]interface{}, len(f))
|
||||
for _, pair := range f {
|
||||
m[pair.Key] = pair.Value
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Get returns the value for a given key and a boolean indicating if the key was found.
|
||||
// This provides O(n) lookup, which is fine for small numbers of fields.
|
||||
// Example:
|
||||
//
|
||||
// fields := lx.Fields{{"user", "alice"}, {"age", 30}}
|
||||
// value, found := fields.Get("user") // Returns "alice", true
|
||||
func (f Fields) Get(key string) (interface{}, bool) {
|
||||
for _, pair := range f {
|
||||
if pair.Key == key {
|
||||
return pair.Value, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Filter returns a new Fields slice containing only pairs where the predicate returns true.
|
||||
// Example:
|
||||
//
|
||||
// fields := lx.Fields{{"user", "alice"}, {"password", "secret"}, {"age", 30}}
|
||||
// filtered := fields.Filter(func(key string, value interface{}) bool {
|
||||
// return key != "password" // Remove sensitive fields
|
||||
// })
|
||||
func (f Fields) Filter(predicate func(key string, value interface{}) bool) Fields {
|
||||
result := make(Fields, 0, len(f))
|
||||
for _, pair := range f {
|
||||
if predicate(pair.Key, pair.Value) {
|
||||
result = append(result, pair)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Translate returns a new Fields slice with keys translated according to the provided mapping.
|
||||
// Keys not in the mapping are passed through unchanged. This is useful for adapters like Victoria.
|
||||
// Example:
|
||||
//
|
||||
// fields := lx.Fields{{"user", "alice"}, {"timestamp", time.Now()}}
|
||||
// translated := fields.Translate(map[string]string{
|
||||
// "user": "username",
|
||||
// "timestamp": "ts",
|
||||
// })
|
||||
// // Returns: {{"username", "alice"}, {"ts", time.Now()}}
|
||||
func (f Fields) Translate(mapping map[string]string) Fields {
|
||||
result := make(Fields, len(f))
|
||||
for i, pair := range f {
|
||||
if newKey, ok := mapping[pair.Key]; ok {
|
||||
result[i] = Field{Key: newKey, Value: pair.Value}
|
||||
} else {
|
||||
result[i] = pair
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Merge merges another Fields slice into this one, with the other slice's fields taking precedence
|
||||
// for duplicate keys (overwrites existing keys).
|
||||
// Example:
|
||||
//
|
||||
// base := lx.Fields{{"user", "alice"}, {"age", 30}}
|
||||
// additional := lx.Fields{{"age", 31}, {"city", "NYC"}}
|
||||
// merged := base.Merge(additional)
|
||||
// // Returns: {{"user", "alice"}, {"age", 31}, {"city", "NYC"}}
|
||||
func (f Fields) Merge(other Fields) Fields {
|
||||
result := make(Fields, 0, len(f)+len(other))
|
||||
|
||||
// Create a map to track which keys from 'other' we've seen
|
||||
seen := make(map[string]bool, len(other))
|
||||
|
||||
// First add all fields from 'f'
|
||||
result = append(result, f...)
|
||||
|
||||
// Then add fields from 'other', overwriting duplicates
|
||||
for _, pair := range other {
|
||||
// Check if this key already exists in result
|
||||
found := false
|
||||
for i, existing := range result {
|
||||
if existing.Key == pair.Key {
|
||||
result[i] = pair // Overwrite
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
result = append(result, pair)
|
||||
}
|
||||
seen[pair.Key] = true
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// String returns a human-readable string representation of the fields.
|
||||
// Example:
|
||||
//
|
||||
// fields := lx.Fields{{"user", "alice"}, {"age", 30}}
|
||||
// str := fields.String() // Returns: "[user=alice age=30]"
|
||||
func (f Fields) String() string {
|
||||
var builder strings.Builder
|
||||
builder.WriteString(LeftBracket)
|
||||
for i, pair := range f {
|
||||
if i > 0 {
|
||||
builder.WriteString(Space)
|
||||
}
|
||||
builder.WriteString(pair.Key)
|
||||
builder.WriteString("=")
|
||||
builder.WriteString(fmt.Sprint(pair.Value))
|
||||
}
|
||||
builder.WriteString(RightBracket)
|
||||
return builder.String()
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package lx
|
||||
|
||||
import "io"
|
||||
|
||||
// Handler defines the interface for processing log entries.
|
||||
// Implementations (e.g., TextHandler, JSONHandler) format and output log entries to various
|
||||
// destinations (e.g., stdout, files). The Handle method returns an error if processing fails,
|
||||
// allowing the logger to handle output failures gracefully.
|
||||
// Example (simplified handler implementation):
|
||||
//
|
||||
// type MyHandler struct{}
|
||||
// func (h *MyHandler) Handle(e *Entry) error {
|
||||
// fmt.Printf("[%s] %s: %s\n", e.Namespace, e.Level.String(), e.Message)
|
||||
// return nil
|
||||
// }
|
||||
type Handler interface {
|
||||
Handle(e *Entry) error // Processes a log entry, returning any error
|
||||
}
|
||||
|
||||
// Outputter defines the interface for handlers that support dynamic output
|
||||
// destination changes. Implementations can switch their output writer at runtime.
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// h := &JSONHandler{}
|
||||
// h.Output(os.Stderr) // Switch to stderr
|
||||
// h.Output(file) // Switch to file
|
||||
type Outputter interface {
|
||||
Output(w io.Writer)
|
||||
}
|
||||
|
||||
// HandlerOutputter combines the Handler and Outputter interfaces.
|
||||
// Types implementing this interface can both process log entries and
|
||||
// dynamically change their output destination at runtime.
|
||||
//
|
||||
// This is useful for creating flexible logging handlers that support
|
||||
// features like log rotation, output redirection, or runtime configuration.
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// var ho HandlerOutputter = &TextHandler{}
|
||||
// // Handle log entries
|
||||
// ho.Handle(&Entry{...})
|
||||
// // Switch output destination
|
||||
// ho.Output(os.Stderr)
|
||||
//
|
||||
// Common implementations include TextHandler and JSONHandler when they
|
||||
// support output destination changes.
|
||||
type HandlerOutputter interface {
|
||||
Handler // Can process log entries
|
||||
Outputter // Can change output destination (has Output(w io.Writer) method)
|
||||
}
|
||||
|
||||
// Timestamper defines an interface for handlers that support timestamp configuration.
|
||||
// It includes a method to enable or disable timestamp logging and optionally set the timestamp format.
|
||||
type Timestamper interface {
|
||||
// Timestamped enables or disables timestamp logging and allows specifying an optional format.
|
||||
// Parameters:
|
||||
// enable: Boolean to enable or disable timestamp logging
|
||||
// format: Optional string(s) to specify the timestamp format
|
||||
Timestamped(enable bool, format ...string)
|
||||
}
|
||||
|
||||
// Wrap is a handler decorator function that transforms a log handler.
|
||||
// It takes an existing handler as input and returns a new, wrapped handler
|
||||
// that adds functionality (like filtering, transformation, or routing).
|
||||
type Wrap func(next Handler) Handler
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package lx
|
||||
|
||||
// Formatting constants for log output.
|
||||
// These constants define the characters used to format log messages, ensuring consistency
|
||||
// across handlers (e.g., text, JSON, colorized). They are used to construct namespace paths,
|
||||
// level indicators, and field separators in log entries.
|
||||
const (
|
||||
Space = " " // Single space for separating elements (e.g., between level and message)
|
||||
DoubleSpace = " " // Double space for indentation (e.g., for hierarchical output)
|
||||
Slash = "/" // Separator for namespace paths (e.g., "parent/child")
|
||||
Arrow = "→" // Arrow for NestedPath style namespaces (e.g., [parent]→[child])
|
||||
LeftBracket = "[" // Opening bracket for namespaces and fields (e.g., [app])
|
||||
RightBracket = "]" // Closing bracket for namespaces and fields (e.g., [app])
|
||||
Colon = ":" // Separator after namespace or level (e.g., [app]: INFO:) can also be "|"
|
||||
Dot = "." // Separator for namespace paths (e.g., "parent.child")
|
||||
Newline = "\n" // Newline for separating log entries or stack trace lines
|
||||
)
|
||||
|
||||
// DefaultEnabled defines the default logging state (disabled).
|
||||
// It specifies whether logging is enabled by default for new Logger instances in the ll package.
|
||||
// Set to false to prevent logging until explicitly enabled.
|
||||
const (
|
||||
DefaultEnabled = true // Default state for new loggers (disabled)
|
||||
)
|
||||
|
||||
// Log level constants, ordered by increasing severity.
|
||||
// These constants define the severity levels for log messages, used to filter logs based
|
||||
// on the logger’s minimum level. They are ordered to allow comparison (e.g., LevelDebug < LevelWarn).
|
||||
const (
|
||||
LevelNone LevelType = iota // Debug level for detailed diagnostic information
|
||||
LevelInfo // Info level for general operational messages
|
||||
LevelWarn // Warn level for warning conditions
|
||||
LevelError // Error level for error conditions requiring attention
|
||||
LevelFatal // Fatal level for critical error conditions
|
||||
LevelDebug // None level for logs without a specific severity (e.g., raw output)
|
||||
LevelUnknown // None level for logs without a specific severity (e.g., raw output)
|
||||
)
|
||||
|
||||
// String constants for each level
|
||||
const (
|
||||
DebugString = "DEBUG"
|
||||
InfoString = "INFO"
|
||||
WarnString = "WARN"
|
||||
WarningString = "WARNING"
|
||||
ErrorString = "ERROR"
|
||||
FatalString = "FATAL"
|
||||
NoneString = "NONE"
|
||||
UnknownString = "UNKNOWN"
|
||||
|
||||
TextString = "TEXT"
|
||||
JSONString = "JSON"
|
||||
DumpString = "DUMP"
|
||||
SpecialString = "SPECIAL"
|
||||
RawString = "RAW"
|
||||
InspectString = "INSPECT"
|
||||
DbgString = "DBG"
|
||||
TimedString = "TIMED"
|
||||
)
|
||||
|
||||
// Log class constants, defining the type of log entry.
|
||||
// These constants categorize log entries by their content or purpose, influencing how
|
||||
// handlers process them (e.g., text, JSON, hex dump).
|
||||
const (
|
||||
ClassText ClassType = iota // Text entries for standard log messages
|
||||
ClassJSON // JSON entries for structured output
|
||||
ClassDump // Dump entries for hex/ASCII dumps
|
||||
ClassSpecial // Special entries for custom or non-standard logs
|
||||
ClassRaw // Raw entries for unformatted output
|
||||
ClassInspect // Inspect entries for debugging
|
||||
ClassDbg // Inspect entries for debugging
|
||||
ClassTimed // Inspect entries for debugging
|
||||
ClassUnknown // Unknown output
|
||||
)
|
||||
|
||||
// Namespace style constants.
|
||||
// These constants define how namespace paths are formatted in log output, affecting the
|
||||
// visual representation of hierarchical namespaces.
|
||||
const (
|
||||
FlatPath StyleType = iota // Formats namespaces as [parent/child]
|
||||
NestedPath // Formats namespaces as [parent]→[child]
|
||||
)
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package lx
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// namespaceRule stores the cached result of Enabled.
|
||||
type namespaceRule struct {
|
||||
isEnabledByRule bool
|
||||
isDisabledByRule bool
|
||||
}
|
||||
|
||||
// Namespace manages thread-safe namespace enable/disable states with caching.
|
||||
// The store holds explicit user-defined rules (path -> bool).
|
||||
// The cache holds computed effective states for paths (path -> namespaceRule)
|
||||
// based on hierarchical rules to optimize lookups.
|
||||
type Namespace struct {
|
||||
store sync.Map // path (string) -> rule (bool: true=enable, false=disable)
|
||||
cache sync.Map // path (string) -> namespaceRule
|
||||
}
|
||||
|
||||
// Set defines an explicit enable/disable rule for a namespace path.
|
||||
// It clears the cache to ensure subsequent lookups reflect the change.
|
||||
func (ns *Namespace) Set(path string, enabled bool) {
|
||||
ns.store.Store(path, enabled)
|
||||
ns.clearCache()
|
||||
}
|
||||
|
||||
// Load retrieves an explicit rule from the store for a path.
|
||||
// Returns the rule (true=enable, false=disable) and whether it exists.
|
||||
// Does not consider hierarchy or caching.
|
||||
func (ns *Namespace) Load(path string) (rule interface{}, found bool) {
|
||||
return ns.store.Load(path)
|
||||
}
|
||||
|
||||
// Store directly sets a rule in the store, bypassing cache invalidation.
|
||||
// Intended for internal use or sync.Map parity; prefer Set for standard use.
|
||||
func (ns *Namespace) Store(path string, rule bool) {
|
||||
ns.store.Store(path, rule)
|
||||
}
|
||||
|
||||
// clearCache clears the cache of Enabled results.
|
||||
// Called by Set to ensure consistency after rule changes.
|
||||
func (ns *Namespace) clearCache() {
|
||||
ns.cache.Range(func(key, _ interface{}) bool {
|
||||
ns.cache.Delete(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// Enabled checks if a path is enabled by namespace rules, considering the most
|
||||
// specific rule (path or closest prefix) in the store. Results are cached.
|
||||
// Args:
|
||||
// - path: Absolute namespace path to check.
|
||||
// - separator: Character delimiting path segments (e.g., "/", ".").
|
||||
//
|
||||
// Returns:
|
||||
// - isEnabledByRule: True if an explicit rule enables the path.
|
||||
// - isDisabledByRule: True if an explicit rule disables the path.
|
||||
//
|
||||
// If both are false, no explicit rule applies to the path or its prefixes.
|
||||
func (ns *Namespace) Enabled(path string, separator string) (isEnabledByRule bool, isDisabledByRule bool) {
|
||||
if path == "" { // Root path has no explicit rule
|
||||
return false, false
|
||||
}
|
||||
|
||||
// Check cache
|
||||
if cachedValue, found := ns.cache.Load(path); found {
|
||||
if state, ok := cachedValue.(namespaceRule); ok {
|
||||
return state.isEnabledByRule, state.isDisabledByRule
|
||||
}
|
||||
ns.cache.Delete(path) // Remove invalid cache entry
|
||||
}
|
||||
|
||||
// Compute: Most specific rule wins
|
||||
parts := strings.Split(path, separator)
|
||||
computedIsEnabled := false
|
||||
computedIsDisabled := false
|
||||
|
||||
for i := len(parts); i >= 1; i-- {
|
||||
currentPrefix := strings.Join(parts[:i], separator)
|
||||
if val, ok := ns.store.Load(currentPrefix); ok {
|
||||
if rule := val.(bool); rule {
|
||||
computedIsEnabled = true
|
||||
computedIsDisabled = false
|
||||
} else {
|
||||
computedIsEnabled = false
|
||||
computedIsDisabled = true
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Cache result, including (false, false) for no rule
|
||||
ns.cache.Store(path, namespaceRule{
|
||||
isEnabledByRule: computedIsEnabled,
|
||||
isDisabledByRule: computedIsDisabled,
|
||||
})
|
||||
|
||||
return computedIsEnabled, computedIsDisabled
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
package lx
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LevelType represents the severity of a log message.
|
||||
// It is an integer type used to define log levels (Debug, Info, Warn, Error, None), with associated
|
||||
// string representations for display in log output.
|
||||
type LevelType int
|
||||
|
||||
// String converts a LevelType to its string representation.
|
||||
// It maps each level constant to a human-readable string, returning "UNKNOWN" for invalid levels.
|
||||
// Used by handlers to display the log level in output.
|
||||
// Example:
|
||||
//
|
||||
// var level lx.LevelType = lx.LevelInfo
|
||||
// fmt.Println(level.String()) // Output: INFO
|
||||
func (l LevelType) String() string {
|
||||
switch l {
|
||||
case LevelDebug:
|
||||
return DebugString
|
||||
case LevelInfo:
|
||||
return InfoString
|
||||
case LevelWarn:
|
||||
return WarnString
|
||||
case LevelError:
|
||||
return ErrorString
|
||||
case LevelFatal:
|
||||
return FatalString
|
||||
case LevelNone:
|
||||
return NoneString
|
||||
default:
|
||||
return UnknownString
|
||||
}
|
||||
}
|
||||
|
||||
func (l LevelType) Name(class ClassType) string {
|
||||
if class == ClassRaw || class == ClassDump || class == ClassInspect || class == ClassDbg || class == ClassTimed {
|
||||
return class.String()
|
||||
}
|
||||
return l.String()
|
||||
}
|
||||
|
||||
// LevelParse converts a string to its corresponding LevelType.
|
||||
// It parses a string (case-insensitive) and returns the corresponding LevelType, defaulting to
|
||||
// LevelUnknown for unrecognized strings. Supports "WARNING" as an alias for "WARN".
|
||||
func LevelParse(s string) LevelType {
|
||||
switch strings.ToUpper(s) {
|
||||
case DebugString:
|
||||
return LevelDebug
|
||||
case InfoString:
|
||||
return LevelInfo
|
||||
case WarnString, WarningString: // Allow both "WARN" and "WARNING"
|
||||
return LevelWarn
|
||||
case ErrorString:
|
||||
return LevelError
|
||||
case NoneString:
|
||||
return LevelNone
|
||||
default:
|
||||
return LevelUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// Entry represents a single log entry passed to handlers.
|
||||
// It encapsulates all information about a log message, including its timestamp, severity,
|
||||
// content, namespace, metadata, and formatting style. Handlers process Entry instances
|
||||
// to produce formatted output (e.g., text, JSON). The struct is immutable once created,
|
||||
// ensuring thread-safety in handler processing.
|
||||
type Entry struct {
|
||||
Timestamp time.Time // Time the log was created
|
||||
Level LevelType // Severity level of the log (Debug, Info, Warn, Error, None)
|
||||
Message string // Log message content
|
||||
Namespace string // Namespace path (e.g., "parent/child")
|
||||
Fields Fields // Additional key-value metadata (e.g., {"user": "alice"})
|
||||
Style StyleType // Namespace formatting style (FlatPath or NestedPath)
|
||||
Error error // Associated error, if any (e.g., for error logs)
|
||||
Class ClassType // Type of log entry (Text, JSON, Dump, Special, Raw)
|
||||
Stack []byte // Stack trace data (if present)
|
||||
Id int `json:"-"` // Unique ID for the entry, ignored in JSON output
|
||||
}
|
||||
|
||||
// StyleType defines how namespace paths are formatted in log output.
|
||||
// It is an integer type used to select between FlatPath ([parent/child]) and NestedPath
|
||||
// ([parent]→[child]) styles, affecting how handlers render namespace hierarchies.
|
||||
type StyleType int
|
||||
|
||||
// ClassType represents the type of a log entry.
|
||||
// It is an integer type used to categorize log entries (Text, JSON, Dump, Special, Raw),
|
||||
// influencing how handlers process and format them.
|
||||
type ClassType int
|
||||
|
||||
// String converts a ClassType to its string representation.
|
||||
// It maps each class constant to a human-readable string, returning "UNKNOWN" for invalid classes.
|
||||
// Used by handlers to indicate the entry type in output (e.g., JSON fields).
|
||||
// Example:
|
||||
//
|
||||
// var class lx.ClassType = lx.ClassText
|
||||
// fmt.Println(class.String()) // Output: TEST
|
||||
func (t ClassType) String() string {
|
||||
switch t {
|
||||
case ClassText:
|
||||
|
||||
return TextString
|
||||
case ClassJSON:
|
||||
|
||||
return JSONString
|
||||
case ClassDump:
|
||||
return DumpString
|
||||
case ClassSpecial:
|
||||
return SpecialString
|
||||
case ClassInspect:
|
||||
return InspectString
|
||||
case ClassDbg:
|
||||
return DbgString
|
||||
case ClassRaw:
|
||||
return RawString
|
||||
case ClassTimed:
|
||||
return TimedString
|
||||
default:
|
||||
return UnknownString
|
||||
}
|
||||
}
|
||||
|
||||
// ParseClass converts a string to its corresponding ClassType.
|
||||
// It parses a string (case-insensitive) and returns the corresponding ClassType, defaulting to
|
||||
// ClassUnknown for unrecognized strings.
|
||||
func ParseClass(s string) ClassType {
|
||||
switch strings.ToUpper(s) {
|
||||
case TextString:
|
||||
return ClassText
|
||||
case JSONString:
|
||||
return ClassJSON
|
||||
case DumpString:
|
||||
return ClassDump
|
||||
case SpecialString:
|
||||
return ClassSpecial
|
||||
case RawString:
|
||||
return ClassRaw
|
||||
default:
|
||||
return ClassUnknown
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package ll
|
||||
|
||||
import (
|
||||
"github.com/olekukonko/ll/lx"
|
||||
)
|
||||
|
||||
// Middleware represents a registered middleware and its operations in the logging pipeline.
|
||||
// It holds an ID for identification, a reference to the parent logger, and the handler function
|
||||
// that processes log entries. Middleware is used to transform or filter log entries before they
|
||||
// are passed to the logger's output handler.
|
||||
type Middleware struct {
|
||||
id int // Unique identifier for the middleware
|
||||
logger *Logger // Parent logger instance for context and logging operations
|
||||
fn lx.Handler // Handler function that processes log entries
|
||||
}
|
||||
|
||||
// Remove unregisters the middleware from the logger’s middleware chain.
|
||||
// It safely removes the middleware by its ID, ensuring thread-safety with a mutex lock.
|
||||
// If the middleware or logger is nil, it returns early to prevent panics.
|
||||
// Example usage:
|
||||
//
|
||||
// // Using a named middleware function
|
||||
// mw := logger.Use(authMiddleware)
|
||||
// defer mw.Remove()
|
||||
//
|
||||
// // Using an inline middleware
|
||||
// mw = logger.Use(ll.Middle(func(e *lx.Entry) error {
|
||||
// if e.Level < lx.LevelWarn {
|
||||
// return fmt.Errorf("level too low")
|
||||
// }
|
||||
// return nil
|
||||
// }))
|
||||
// defer mw.Remove()
|
||||
func (m *Middleware) Remove() {
|
||||
// Check for nil middleware or logger to avoid panics
|
||||
if m == nil || m.logger == nil {
|
||||
return
|
||||
}
|
||||
// Acquire write lock to modify middleware slice
|
||||
m.logger.mu.Lock()
|
||||
defer m.logger.mu.Unlock()
|
||||
// Iterate through middleware slice to find and remove matching ID
|
||||
for i, entry := range m.logger.middleware {
|
||||
if entry.id == m.id {
|
||||
// Remove middleware by slicing out the matching entry
|
||||
m.logger.middleware = append(m.logger.middleware[:i], m.logger.middleware[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Logger returns the parent logger for optional chaining.
|
||||
// This allows middleware to access the logger for additional operations, such as logging errors
|
||||
// or creating derived loggers. It is useful for fluent API patterns.
|
||||
// Example:
|
||||
//
|
||||
// mw := logger.Use(authMiddleware)
|
||||
// mw.Logger().Info("Middleware registered")
|
||||
func (m *Middleware) Logger() *Logger {
|
||||
return m.logger
|
||||
}
|
||||
|
||||
// Error logs an error message at the Error level if the middleware blocks a log entry.
|
||||
// It uses the parent logger to emit the error and returns the middleware for chaining.
|
||||
// This is useful for debugging or auditing when middleware rejects a log.
|
||||
// Example:
|
||||
//
|
||||
// mw := logger.Use(ll.Middle(func(e *lx.Entry) error {
|
||||
// if e.Level < lx.LevelWarn {
|
||||
// return fmt.Errorf("level too low")
|
||||
// }
|
||||
// return nil
|
||||
// }))
|
||||
// mw.Error("Rejected low-level log")
|
||||
func (m *Middleware) Error(args ...any) *Middleware {
|
||||
m.logger.Error(args...)
|
||||
return m
|
||||
}
|
||||
|
||||
// Errorf logs an error message at the Error level if the middleware blocks a log entry.
|
||||
// It uses the parent logger to emit the error and returns the middleware for chaining.
|
||||
// This is useful for debugging or auditing when middleware rejects a log.
|
||||
// Example:
|
||||
//
|
||||
// mw := logger.Use(ll.Middle(func(e *lx.Entry) error {
|
||||
// if e.Level < lx.LevelWarn {
|
||||
// return fmt.Errorf("level too low")
|
||||
// }
|
||||
// return nil
|
||||
// }))
|
||||
// mw.Errorf("Rejected low-level log")
|
||||
func (m *Middleware) Errorf(format string, args ...any) *Middleware {
|
||||
m.logger.Errorf(format, args...)
|
||||
return m
|
||||
}
|
||||
|
||||
// middlewareFunc is a function adapter that implements the lx.Handler interface.
|
||||
// It allows plain functions with the signature `func(*lx.Entry) error` to be used as middleware.
|
||||
// The function should return nil to allow the log to proceed or a non-nil error to reject it,
|
||||
// stopping the log from being emitted by the logger.
|
||||
type middlewareFunc func(*lx.Entry) error
|
||||
|
||||
// Handle implements the lx.Handler interface for middlewareFunc.
|
||||
// It calls the underlying function with the log entry and returns its result.
|
||||
// This enables seamless integration of function-based middleware into the logging pipeline.
|
||||
func (mf middlewareFunc) Handle(e *lx.Entry) error {
|
||||
return mf(e)
|
||||
}
|
||||
|
||||
// Middle creates a middleware handler from a function.
|
||||
// It wraps a function with the signature `func(*lx.Entry) error` into a middlewareFunc,
|
||||
// allowing it to be used in the logger’s middleware pipeline. A non-nil error returned by
|
||||
// the function will stop the log from being emitted, ensuring precise control over logging.
|
||||
// Example:
|
||||
//
|
||||
// logger.Use(ll.Middle(func(e *lx.Entry) error {
|
||||
// if e.Level == lx.LevelDebug {
|
||||
// return fmt.Errorf("debug logs disabled")
|
||||
// }
|
||||
// return nil
|
||||
// }))
|
||||
func Middle(fn func(*lx.Entry) error) lx.Handler {
|
||||
return middlewareFunc(fn)
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package ll
|
||||
|
||||
import "github.com/olekukonko/ll/lx"
|
||||
|
||||
// WithHandler sets the handler for the logger as a functional option for configuring
|
||||
// a new logger instance.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app", WithHandler(lh.NewJSONHandler(os.Stdout)))
|
||||
func WithHandler(handler lx.Handler) Option {
|
||||
return func(l *Logger) {
|
||||
l.handler = handler
|
||||
}
|
||||
}
|
||||
|
||||
// WithTimestamped returns an Option that configures timestamp settings for the logger's existing handler.
|
||||
// It enables or disables timestamp logging and optionally sets the timestamp format if the handler
|
||||
// supports the lx.Timestamper interface. If no handler is set, the function has no effect.
|
||||
// Parameters:
|
||||
//
|
||||
// enable: Boolean to enable or disable timestamp logging
|
||||
// format: Optional string(s) to specify the timestamp format
|
||||
func WithTimestamped(enable bool, format ...string) Option {
|
||||
return func(l *Logger) {
|
||||
if l.handler != nil { // Check if a handler is set
|
||||
// Verify if the handler supports the lx.Timestamper interface
|
||||
if h, ok := l.handler.(lx.Timestamper); ok {
|
||||
h.Timestamped(enable, format...) // Apply timestamp settings to the handler
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithLevel sets the minimum log level for the logger as a functional option for
|
||||
// configuring a new logger instance.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app", WithLevel(lx.LevelWarn))
|
||||
func WithLevel(level lx.LevelType) Option {
|
||||
return func(l *Logger) {
|
||||
l.level = level
|
||||
}
|
||||
}
|
||||
|
||||
// WithStyle sets the namespace formatting style for the logger as a functional option
|
||||
// for configuring a new logger instance.
|
||||
// Example:
|
||||
//
|
||||
// logger := New("app", WithStyle(lx.NestedPath))
|
||||
func WithStyle(style lx.StyleType) Option {
|
||||
return func(l *Logger) {
|
||||
l.style = style
|
||||
}
|
||||
}
|
||||
|
||||
// Functional options (can be passed to New() or applied later)
|
||||
func WithFatalExits(enabled bool) Option {
|
||||
return func(l *Logger) {
|
||||
l.fatalExits = enabled
|
||||
}
|
||||
}
|
||||
|
||||
func WithFatalStack(enabled bool) Option {
|
||||
return func(l *Logger) {
|
||||
l.fatalStack = enabled
|
||||
}
|
||||
}
|
||||
+388
@@ -0,0 +1,388 @@
|
||||
package ll
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/olekukonko/ll/lx"
|
||||
)
|
||||
|
||||
// Measure executes one or more functions and logs the duration of each.
|
||||
// It returns the total cumulative duration across all functions.
|
||||
//
|
||||
// Each function in `fns` is run sequentially. If a function is `nil`, it is skipped.
|
||||
//
|
||||
// Optional labels previously set via `Labels(...)` are applied to the corresponding function
|
||||
// by position. If there are fewer labels than functions, missing labels are replaced with
|
||||
// default names like "fn_0", "fn_1", etc. Labels are cleared after the call to prevent reuse.
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// logger := New("app").Enable()
|
||||
//
|
||||
// // Optional: add labels for functions
|
||||
// logger.Labels("load_users", "process_orders")
|
||||
//
|
||||
// total := logger.Measure(
|
||||
// func() {
|
||||
// // simulate work 1
|
||||
// time.Sleep(100 * time.Millisecond)
|
||||
// },
|
||||
// func() {
|
||||
// // simulate work 2
|
||||
// time.Sleep(200 * time.Millisecond)
|
||||
// },
|
||||
// func() {
|
||||
// // simulate work 3
|
||||
// time.Sleep(50 * time.Millisecond)
|
||||
// },
|
||||
// )
|
||||
//
|
||||
// // Logs something like:
|
||||
// // [load_users] completed duration=100ms
|
||||
// // [process_orders] completed duration=200ms
|
||||
// // [fn_2] completed duration=50ms
|
||||
//
|
||||
// Returns the sum of durations of all executed functions.
|
||||
func (l *Logger) Measure(fns ...func()) time.Duration {
|
||||
if len(fns) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
var total time.Duration
|
||||
lblPtr := l.labels.Swap(nil)
|
||||
var lbls []string
|
||||
if lblPtr != nil {
|
||||
lbls = *lblPtr
|
||||
}
|
||||
|
||||
for i, fn := range fns {
|
||||
if fn == nil {
|
||||
continue
|
||||
}
|
||||
// Use SinceBuilder instead of manual timing
|
||||
sb := l.Since() // starts timer internally
|
||||
fn()
|
||||
duration := sb.Fields(
|
||||
"index", i,
|
||||
).Info(fmt.Sprintf("[%s] completed", func() string {
|
||||
if i < len(lbls) && lbls[i] != "" {
|
||||
return lbls[i]
|
||||
}
|
||||
return fmt.Sprintf("fn_%d", i)
|
||||
}()))
|
||||
|
||||
total += duration
|
||||
}
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
// Since creates a timer that will log the duration when completed
|
||||
// If startTime is provided, uses that as the start time; otherwise uses time.Now()
|
||||
//
|
||||
// defer logger.Since().Info("request") // Auto-start
|
||||
// logger.Since(start).Info("request") // Manual timing
|
||||
// logger.Since().If(debug).Debug("timing") // Conditional
|
||||
func (l *Logger) Since(startTime ...time.Time) *SinceBuilder {
|
||||
start := time.Now()
|
||||
if len(startTime) > 0 && !startTime[0].IsZero() {
|
||||
start = startTime[0]
|
||||
}
|
||||
|
||||
return &SinceBuilder{
|
||||
logger: l,
|
||||
start: start,
|
||||
condition: true,
|
||||
fields: nil, // Lazily initialized
|
||||
}
|
||||
}
|
||||
|
||||
// SinceBuilder provides a fluent API for logging timed operations
|
||||
// It mirrors FieldBuilder exactly for field operations
|
||||
type SinceBuilder struct {
|
||||
logger *Logger
|
||||
start time.Time
|
||||
condition bool
|
||||
fields lx.Fields
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Conditional Methods (match conditional.go pattern)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// If adds a condition to this timer - only logs if condition is true
|
||||
func (sb *SinceBuilder) If(condition bool) *SinceBuilder {
|
||||
sb.condition = sb.condition && condition
|
||||
return sb
|
||||
}
|
||||
|
||||
// IfErr adds an error condition - only logs if err != nil
|
||||
func (sb *SinceBuilder) IfErr(err error) *SinceBuilder {
|
||||
sb.condition = sb.condition && (err != nil)
|
||||
return sb
|
||||
}
|
||||
|
||||
// IfAny logs if ANY condition is true
|
||||
func (sb *SinceBuilder) IfAny(conditions ...bool) *SinceBuilder {
|
||||
if !sb.condition {
|
||||
return sb
|
||||
}
|
||||
|
||||
for _, cond := range conditions {
|
||||
if cond {
|
||||
return sb
|
||||
}
|
||||
}
|
||||
sb.condition = false
|
||||
return sb
|
||||
}
|
||||
|
||||
// IfOne logs if ALL conditions are true
|
||||
func (sb *SinceBuilder) IfOne(conditions ...bool) *SinceBuilder {
|
||||
if !sb.condition {
|
||||
return sb
|
||||
}
|
||||
|
||||
for _, cond := range conditions {
|
||||
if !cond {
|
||||
sb.condition = false
|
||||
return sb
|
||||
}
|
||||
}
|
||||
return sb
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Field Methods - EXACT MATCH with FieldBuilder API
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// Fields adds key-value pairs as fields (variadic)
|
||||
// EXACT match to FieldBuilder.Fields()
|
||||
func (sb *SinceBuilder) Fields(pairs ...any) *SinceBuilder {
|
||||
if sb.logger.suspend.Load() || !sb.condition {
|
||||
return sb
|
||||
}
|
||||
|
||||
// Lazy initialization
|
||||
if sb.fields == nil {
|
||||
sb.fields = make(lx.Fields, 0, len(pairs)/2)
|
||||
}
|
||||
|
||||
// Process key-value pairs
|
||||
for i := 0; i < len(pairs)-1; i += 2 {
|
||||
if key, ok := pairs[i].(string); ok {
|
||||
sb.fields = append(sb.fields, lx.Field{Key: key, Value: pairs[i+1]})
|
||||
} else {
|
||||
// Log error for non-string keys (matches Fields behavior)
|
||||
sb.fields = append(sb.fields, lx.Field{
|
||||
Key: "error",
|
||||
Value: fmt.Errorf("missing key '%v'", pairs[i]),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Handle uneven pairs (matches Fields behavior)
|
||||
if len(pairs)%2 != 0 {
|
||||
sb.fields = append(sb.fields, lx.Field{
|
||||
Key: "error",
|
||||
Value: fmt.Errorf("missing key '%v'", pairs[len(pairs)-1]),
|
||||
})
|
||||
}
|
||||
|
||||
return sb
|
||||
}
|
||||
|
||||
// Field adds fields from a map
|
||||
// EXACT match to FieldBuilder.Field()
|
||||
func (sb *SinceBuilder) Field(fields map[string]interface{}) *SinceBuilder {
|
||||
if sb.logger.suspend.Load() || !sb.condition || len(fields) == 0 {
|
||||
return sb
|
||||
}
|
||||
|
||||
// Lazy initialization
|
||||
if sb.fields == nil {
|
||||
sb.fields = make(lx.Fields, 0, len(fields))
|
||||
}
|
||||
|
||||
// Copy fields from input map (preserves iteration order)
|
||||
for k, v := range fields {
|
||||
sb.fields = append(sb.fields, lx.Field{Key: k, Value: v})
|
||||
}
|
||||
|
||||
return sb
|
||||
}
|
||||
|
||||
// Err adds one or more errors as a field
|
||||
// EXACT match to FieldBuilder.Err()
|
||||
func (sb *SinceBuilder) Err(errs ...error) *SinceBuilder {
|
||||
if sb.logger.suspend.Load() || !sb.condition {
|
||||
return sb
|
||||
}
|
||||
|
||||
// Lazy initialization
|
||||
if sb.fields == nil {
|
||||
sb.fields = make(lx.Fields, 0, 2)
|
||||
}
|
||||
|
||||
// Collect non-nil errors
|
||||
var nonNilErrors []error
|
||||
var builder strings.Builder
|
||||
count := 0
|
||||
|
||||
for i, err := range errs {
|
||||
if err != nil {
|
||||
if i > 0 && count > 0 {
|
||||
builder.WriteString("; ")
|
||||
}
|
||||
builder.WriteString(err.Error())
|
||||
nonNilErrors = append(nonNilErrors, err)
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
if count == 1 {
|
||||
sb.fields = append(sb.fields, lx.Field{Key: "error", Value: nonNilErrors[0]})
|
||||
} else {
|
||||
sb.fields = append(sb.fields, lx.Field{Key: "error", Value: nonNilErrors})
|
||||
}
|
||||
// Note: Unlike FieldBuilder.Err(), we DON'T log immediately
|
||||
// The error will be included in the timing log
|
||||
}
|
||||
|
||||
return sb
|
||||
}
|
||||
|
||||
// Merge adds additional key-value pairs to the fields
|
||||
// EXACT match to FieldBuilder.Merge()
|
||||
func (sb *SinceBuilder) Merge(pairs ...any) *SinceBuilder {
|
||||
if sb.logger.suspend.Load() || !sb.condition {
|
||||
return sb
|
||||
}
|
||||
|
||||
// Lazy initialization
|
||||
if sb.fields == nil {
|
||||
sb.fields = make(lx.Fields, 0, len(pairs)/2)
|
||||
}
|
||||
|
||||
// Process pairs as key-value
|
||||
for i := 0; i < len(pairs)-1; i += 2 {
|
||||
if key, ok := pairs[i].(string); ok {
|
||||
sb.fields = append(sb.fields, lx.Field{Key: key, Value: pairs[i+1]})
|
||||
} else {
|
||||
sb.fields = append(sb.fields, lx.Field{
|
||||
Key: "error",
|
||||
Value: fmt.Errorf("non-string key in Merge: %v", pairs[i]),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(pairs)%2 != 0 {
|
||||
sb.fields = append(sb.fields, lx.Field{
|
||||
Key: "error",
|
||||
Value: fmt.Errorf("uneven key-value pairs in Merge: [%v]", pairs[len(pairs)-1]),
|
||||
})
|
||||
}
|
||||
|
||||
return sb
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Logging Methods (match logger pattern)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// Debug logs the duration at Debug level with message
|
||||
func (sb *SinceBuilder) Debug(msg string) time.Duration {
|
||||
return sb.logAtLevel(lx.LevelDebug, msg)
|
||||
}
|
||||
|
||||
// Info logs the duration at Info level with message
|
||||
func (sb *SinceBuilder) Info(msg string) time.Duration {
|
||||
return sb.logAtLevel(lx.LevelInfo, msg)
|
||||
}
|
||||
|
||||
// Warn logs the duration at Warn level with message
|
||||
func (sb *SinceBuilder) Warn(msg string) time.Duration {
|
||||
return sb.logAtLevel(lx.LevelWarn, msg)
|
||||
}
|
||||
|
||||
// Error logs the duration at Error level with message
|
||||
func (sb *SinceBuilder) Error(msg string) time.Duration {
|
||||
return sb.logAtLevel(lx.LevelError, msg)
|
||||
}
|
||||
|
||||
// Log is an alias for Info (for backward compatibility)
|
||||
func (sb *SinceBuilder) Log(msg string) time.Duration {
|
||||
return sb.Info(msg)
|
||||
}
|
||||
|
||||
// logAtLevel internal method that handles the actual logging
|
||||
func (sb *SinceBuilder) logAtLevel(level lx.LevelType, msg string) time.Duration {
|
||||
// Fast path - don't even compute duration if we're not logging
|
||||
if !sb.condition || sb.logger.suspend.Load() || !sb.logger.shouldLog(level) {
|
||||
return time.Since(sb.start)
|
||||
}
|
||||
|
||||
duration := time.Since(sb.start)
|
||||
|
||||
// Build final fields in this order:
|
||||
// 1. Logger context fields (from logger.context)
|
||||
// 2. Builder fields (from sb.fields)
|
||||
// 3. Duration fields (always last)
|
||||
|
||||
// Pre-allocate with exact capacity
|
||||
totalFields := 0
|
||||
if sb.logger.context != nil {
|
||||
totalFields += len(sb.logger.context)
|
||||
}
|
||||
if sb.fields != nil {
|
||||
totalFields += len(sb.fields)
|
||||
}
|
||||
totalFields += 2 // duration_ms, duration
|
||||
|
||||
fields := make(lx.Fields, 0, totalFields)
|
||||
|
||||
// Add logger context fields first (preserves order)
|
||||
if sb.logger.context != nil {
|
||||
fields = append(fields, sb.logger.context...)
|
||||
}
|
||||
|
||||
// Add builder fields
|
||||
if sb.fields != nil {
|
||||
fields = append(fields, sb.fields...)
|
||||
}
|
||||
|
||||
// Add duration fields last (so they're visible at the end)
|
||||
fields = append(fields,
|
||||
lx.Field{Key: "duration_ms", Value: duration.Milliseconds()},
|
||||
lx.Field{Key: "duration", Value: duration.String()},
|
||||
)
|
||||
|
||||
sb.logger.log(level, lx.ClassTimed, msg, fields, false)
|
||||
return duration
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Utility Methods
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// Reset allows reusing the builder with a new start time
|
||||
// Zero-allocation - keeps fields slice capacity
|
||||
func (sb *SinceBuilder) Reset(startTime ...time.Time) *SinceBuilder {
|
||||
sb.start = time.Now()
|
||||
if len(startTime) > 0 && !startTime[0].IsZero() {
|
||||
sb.start = startTime[0]
|
||||
}
|
||||
sb.condition = true
|
||||
if sb.fields != nil {
|
||||
sb.fields = sb.fields[:0] // Keep capacity, zero length
|
||||
}
|
||||
return sb
|
||||
}
|
||||
|
||||
// Elapsed returns the current duration without logging
|
||||
func (sb *SinceBuilder) Elapsed() time.Duration {
|
||||
return time.Since(sb.start)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
|
||||
|
||||
# folders
|
||||
.idea
|
||||
.vscode
|
||||
/tmp
|
||||
/lab
|
||||
dev.sh
|
||||
*csv2table
|
||||
_test/
|
||||
*.test
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
# See for configurations: https://golangci-lint.run/usage/configuration/
|
||||
version: 2
|
||||
# See: https://golangci-lint.run/usage/formatters/
|
||||
formatters:
|
||||
default: none
|
||||
enable:
|
||||
- gofmt # https://pkg.go.dev/cmd/gofmt
|
||||
- gofumpt # https://github.com/mvdan/gofumpt
|
||||
|
||||
settings:
|
||||
gofmt:
|
||||
simplify: true # Simplify code: gofmt with `-s` option.
|
||||
|
||||
gofumpt:
|
||||
# Module path which contains the source code being formatted.
|
||||
# Default: ""
|
||||
module-path: github.com/olekukonko/tablewriter # Should match with module in go.mod
|
||||
# Choose whether to use the extra rules.
|
||||
# Default: false
|
||||
extra-rules: true
|
||||
|
||||
# See: https://golangci-lint.run/usage/linters/
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
- staticcheck
|
||||
- govet
|
||||
- gocritic
|
||||
# - unused # TODO: There are many unused functions, should I directly remove those ?
|
||||
- ineffassign
|
||||
- unconvert
|
||||
- mirror
|
||||
- usestdlibvars
|
||||
- loggercheck
|
||||
- exptostd
|
||||
- godot
|
||||
- perfsprint
|
||||
|
||||
# See: https://golangci-lint.run/usage/false-positives/
|
||||
exclusion:
|
||||
# paths:
|
||||
# rules:
|
||||
|
||||
settings:
|
||||
staticcheck:
|
||||
checks:
|
||||
- all
|
||||
- "-SA1019" # disabled because it warns about deprecated: kept for compatibility will be removed soon
|
||||
- "-ST1019" # disabled because it warns about deprecated: kept for compatibility will be removed soon
|
||||
- "-ST1021" # disabled because it warns to have comment on exported packages
|
||||
- "-ST1000" # disabled because it warns to have comment on exported functions
|
||||
- "-ST1020" # disabled because it warns to have at least one file in a package should have a package comment
|
||||
|
||||
godot:
|
||||
period: false
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
Copyright (C) 2014 by Oleku Konko
|
||||
|
||||
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:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
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.
|
||||
+3280
File diff suppressed because it is too large
Load Diff
+1103
File diff suppressed because it is too large
Load Diff
+466
@@ -0,0 +1,466 @@
|
||||
ASCII Table Writer
|
||||
=========
|
||||
|
||||
[](https://github.com/olekukonko/tablewriter/actions?query=workflow%3Aci)
|
||||
[](https://sourcegraph.com/github.com/olekukonko/tablewriter)
|
||||
[](https://godoc.org/github.com/olekukonko/tablewriter)
|
||||
|
||||
|
||||
## Important Notice: Modernization in Progress
|
||||
|
||||
The `tablewriter` package is being modernized on the `prototype` branch with generics, streaming support, and a modular design, targeting `v0.2.0`. Until this is released:
|
||||
|
||||
**For Production Use**: Use the stable version `v0.0.5`:
|
||||
```bash
|
||||
go get github.com/olekukonko/tablewriter@v0.0.5
|
||||
```
|
||||
|
||||
####
|
||||
|
||||
For Development Preview: Try the in-progress version (unstable)
|
||||
|
||||
```bash
|
||||
go get github.com/olekukonko/tablewriter@master
|
||||
```
|
||||
|
||||
#### Features
|
||||
|
||||
- Automatic Padding
|
||||
- Support Multiple Lines
|
||||
- Supports Alignment
|
||||
- Support Custom Separators
|
||||
- Automatic Alignment of numbers & percentage
|
||||
- Write directly to http , file etc via `io.Writer`
|
||||
- Read directly from CSV file
|
||||
- Optional row line via `SetRowLine`
|
||||
- Normalise table header
|
||||
- Make CSV Headers optional
|
||||
- Enable or disable table border
|
||||
- Set custom footer support
|
||||
- Optional identical cells merging
|
||||
- Set custom caption
|
||||
- Optional reflowing of paragraphs in multi-line cells.
|
||||
|
||||
#### Example 1 - Basic
|
||||
|
||||
```go
|
||||
data := [][]string{
|
||||
[]string{"A", "The Good", "500"},
|
||||
[]string{"B", "The Very very Bad Man", "288"},
|
||||
[]string{"C", "The Ugly", "120"},
|
||||
[]string{"D", "The Gopher", "800"},
|
||||
}
|
||||
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
table.SetHeader([]string{"Name", "Sign", "Rating"})
|
||||
|
||||
for _, v := range data {
|
||||
table.Append(v)
|
||||
}
|
||||
table.Render() // Send output
|
||||
```
|
||||
|
||||
##### Output 1
|
||||
|
||||
```
|
||||
+------+-----------------------+--------+
|
||||
| NAME | SIGN | RATING |
|
||||
+------+-----------------------+--------+
|
||||
| A | The Good | 500 |
|
||||
| B | The Very very Bad Man | 288 |
|
||||
| C | The Ugly | 120 |
|
||||
| D | The Gopher | 800 |
|
||||
+------+-----------------------+--------+
|
||||
```
|
||||
|
||||
#### Example 2 - Without Border / Footer / Bulk Append
|
||||
|
||||
```go
|
||||
data := [][]string{
|
||||
[]string{"1/1/2014", "Domain name", "2233", "$10.98"},
|
||||
[]string{"1/1/2014", "January Hosting", "2233", "$54.95"},
|
||||
[]string{"1/4/2014", "February Hosting", "2233", "$51.00"},
|
||||
[]string{"1/4/2014", "February Extra Bandwidth", "2233", "$30.00"},
|
||||
}
|
||||
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
table.SetHeader([]string{"Date", "Description", "CV2", "Amount"})
|
||||
table.SetFooter([]string{"", "", "Total", "$146.93"}) // Add Footer
|
||||
table.EnableBorder(false) // Set Border to false
|
||||
table.AppendBulk(data) // Add Bulk Data
|
||||
table.Render()
|
||||
```
|
||||
|
||||
##### Output 2
|
||||
|
||||
```
|
||||
|
||||
DATE | DESCRIPTION | CV2 | AMOUNT
|
||||
-----------+--------------------------+-------+----------
|
||||
1/1/2014 | Domain name | 2233 | $10.98
|
||||
1/1/2014 | January Hosting | 2233 | $54.95
|
||||
1/4/2014 | February Hosting | 2233 | $51.00
|
||||
1/4/2014 | February Extra Bandwidth | 2233 | $30.00
|
||||
-----------+--------------------------+-------+----------
|
||||
TOTAL | $146 93
|
||||
--------+----------
|
||||
|
||||
```
|
||||
|
||||
#### Example 3 - CSV
|
||||
|
||||
```go
|
||||
table, _ := tablewriter.NewCSV(os.Stdout, "testdata/test_info.csv", true)
|
||||
table.SetAlignment(tablewriter.ALIGN_LEFT) // Set Alignment
|
||||
table.Render()
|
||||
```
|
||||
|
||||
##### Output 3
|
||||
|
||||
```
|
||||
+----------+--------------+------+-----+---------+----------------+
|
||||
| FIELD | TYPE | NULL | KEY | DEFAULT | EXTRA |
|
||||
+----------+--------------+------+-----+---------+----------------+
|
||||
| user_id | smallint(5) | NO | PRI | NULL | auto_increment |
|
||||
| username | varchar(10) | NO | | NULL | |
|
||||
| password | varchar(100) | NO | | NULL | |
|
||||
+----------+--------------+------+-----+---------+----------------+
|
||||
```
|
||||
|
||||
#### Example 4 - Custom Separator
|
||||
|
||||
```go
|
||||
table, _ := tablewriter.NewCSV(os.Stdout, "testdata/test.csv", true)
|
||||
table.SetRowLine(true) // Enable row line
|
||||
|
||||
// Change table lines
|
||||
table.SetCenterSeparator("*")
|
||||
table.SetColumnSeparator("╪")
|
||||
table.SetRowSeparator("-")
|
||||
|
||||
table.SetAlignment(tablewriter.ALIGN_LEFT)
|
||||
table.Render()
|
||||
```
|
||||
|
||||
##### Output 4
|
||||
|
||||
```
|
||||
*------------*-----------*---------*
|
||||
╪ FIRST NAME ╪ LAST NAME ╪ SSN ╪
|
||||
*------------*-----------*---------*
|
||||
╪ John ╪ Barry ╪ 123456 ╪
|
||||
*------------*-----------*---------*
|
||||
╪ Kathy ╪ Smith ╪ 687987 ╪
|
||||
*------------*-----------*---------*
|
||||
╪ Bob ╪ McCornick ╪ 3979870 ╪
|
||||
*------------*-----------*---------*
|
||||
```
|
||||
|
||||
#### Example 5 - Markdown Format
|
||||
|
||||
```go
|
||||
data := [][]string{
|
||||
[]string{"1/1/2014", "Domain name", "2233", "$10.98"},
|
||||
[]string{"1/1/2014", "January Hosting", "2233", "$54.95"},
|
||||
[]string{"1/4/2014", "February Hosting", "2233", "$51.00"},
|
||||
[]string{"1/4/2014", "February Extra Bandwidth", "2233", "$30.00"},
|
||||
}
|
||||
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
table.SetHeader([]string{"Date", "Description", "CV2", "Amount"})
|
||||
table.SetBorders(tablewriter.Border{Left: true, Top: false, Right: true, Bottom: false})
|
||||
table.SetCenterSeparator("|")
|
||||
table.AppendBulk(data) // Add Bulk Data
|
||||
table.Render()
|
||||
```
|
||||
|
||||
##### Output 5
|
||||
|
||||
```
|
||||
| DATE | DESCRIPTION | CV2 | AMOUNT |
|
||||
|----------|--------------------------|------|--------|
|
||||
| 1/1/2014 | Domain name | 2233 | $10.98 |
|
||||
| 1/1/2014 | January Hosting | 2233 | $54.95 |
|
||||
| 1/4/2014 | February Hosting | 2233 | $51.00 |
|
||||
| 1/4/2014 | February Extra Bandwidth | 2233 | $30.00 |
|
||||
```
|
||||
|
||||
#### Example 6 - Identical cells merging
|
||||
|
||||
```go
|
||||
data := [][]string{
|
||||
[]string{"1/1/2014", "Domain name", "1234", "$10.98"},
|
||||
[]string{"1/1/2014", "January Hosting", "2345", "$54.95"},
|
||||
[]string{"1/4/2014", "February Hosting", "3456", "$51.00"},
|
||||
[]string{"1/4/2014", "February Extra Bandwidth", "4567", "$30.00"},
|
||||
}
|
||||
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
table.SetHeader([]string{"Date", "Description", "CV2", "Amount"})
|
||||
table.SetFooter([]string{"", "", "Total", "$146.93"})
|
||||
table.SetAutoMergeCells(true)
|
||||
table.SetRowLine(true)
|
||||
table.AppendBulk(data)
|
||||
table.Render()
|
||||
```
|
||||
|
||||
##### Output 6
|
||||
|
||||
```
|
||||
+----------+--------------------------+-------+---------+
|
||||
| DATE | DESCRIPTION | CV2 | AMOUNT |
|
||||
+----------+--------------------------+-------+---------+
|
||||
| 1/1/2014 | Domain name | 1234 | $10.98 |
|
||||
+ +--------------------------+-------+---------+
|
||||
| | January Hosting | 2345 | $54.95 |
|
||||
+----------+--------------------------+-------+---------+
|
||||
| 1/4/2014 | February Hosting | 3456 | $51.00 |
|
||||
+ +--------------------------+-------+---------+
|
||||
| | February Extra Bandwidth | 4567 | $30.00 |
|
||||
+----------+--------------------------+-------+---------+
|
||||
| TOTAL | $146 93 |
|
||||
+----------+--------------------------+-------+---------+
|
||||
```
|
||||
|
||||
#### Example 7 - Identical cells merging (specify the column index to merge)
|
||||
|
||||
```go
|
||||
data := [][]string{
|
||||
[]string{"1/1/2014", "Domain name", "1234", "$10.98"},
|
||||
[]string{"1/1/2014", "January Hosting", "1234", "$10.98"},
|
||||
[]string{"1/4/2014", "February Hosting", "3456", "$51.00"},
|
||||
[]string{"1/4/2014", "February Extra Bandwidth", "4567", "$30.00"},
|
||||
}
|
||||
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
table.SetHeader([]string{"Date", "Description", "CV2", "Amount"})
|
||||
table.SetFooter([]string{"", "", "Total", "$146.93"})
|
||||
table.SetAutoMergeCellsByColumnIndex([]int{2, 3})
|
||||
table.SetRowLine(true)
|
||||
table.AppendBulk(data)
|
||||
table.Render()
|
||||
```
|
||||
|
||||
##### Output 7
|
||||
|
||||
```
|
||||
+----------+--------------------------+-------+---------+
|
||||
| DATE | DESCRIPTION | CV2 | AMOUNT |
|
||||
+----------+--------------------------+-------+---------+
|
||||
| 1/1/2014 | Domain name | 1234 | $10.98 |
|
||||
+----------+--------------------------+ + +
|
||||
| 1/1/2014 | January Hosting | | |
|
||||
+----------+--------------------------+-------+---------+
|
||||
| 1/4/2014 | February Hosting | 3456 | $51.00 |
|
||||
+----------+--------------------------+-------+---------+
|
||||
| 1/4/2014 | February Extra Bandwidth | 4567 | $30.00 |
|
||||
+----------+--------------------------+-------+---------+
|
||||
| TOTAL | $146.93 |
|
||||
+----------+--------------------------+-------+---------+
|
||||
```
|
||||
|
||||
#### Table with color
|
||||
|
||||
```go
|
||||
data := [][]string{
|
||||
[]string{"1/1/2014", "Domain name", "2233", "$10.98"},
|
||||
[]string{"1/1/2014", "January Hosting", "2233", "$54.95"},
|
||||
[]string{"1/4/2014", "February Hosting", "2233", "$51.00"},
|
||||
[]string{"1/4/2014", "February Extra Bandwidth", "2233", "$30.00"},
|
||||
}
|
||||
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
table.SetHeader([]string{"Date", "Description", "CV2", "Amount"})
|
||||
table.SetFooter([]string{"", "", "Total", "$146.93"}) // Add Footer
|
||||
table.EnableBorder(false) // Set Border to false
|
||||
|
||||
table.SetHeaderColor(tablewriter.Colors{tablewriter.Bold, tablewriter.BgGreenColor},
|
||||
tablewriter.Colors{tablewriter.FgHiRedColor, tablewriter.Bold, tablewriter.BgBlackColor},
|
||||
tablewriter.Colors{tablewriter.BgRedColor, tablewriter.FgWhiteColor},
|
||||
tablewriter.Colors{tablewriter.BgCyanColor, tablewriter.FgWhiteColor})
|
||||
|
||||
table.SetColumnColor(tablewriter.Colors{tablewriter.Bold, tablewriter.FgHiBlackColor},
|
||||
tablewriter.Colors{tablewriter.Bold, tablewriter.FgHiRedColor},
|
||||
tablewriter.Colors{tablewriter.Bold, tablewriter.FgHiBlackColor},
|
||||
tablewriter.Colors{tablewriter.Bold, tablewriter.FgBlackColor})
|
||||
|
||||
table.SetFooterColor(tablewriter.Colors{}, tablewriter.Colors{},
|
||||
tablewriter.Colors{tablewriter.Bold},
|
||||
tablewriter.Colors{tablewriter.FgHiRedColor})
|
||||
|
||||
table.AppendBulk(data)
|
||||
table.Render()
|
||||
```
|
||||
|
||||
#### Table with color Output
|
||||
|
||||

|
||||
|
||||
#### Example - 8 Table Cells with Color
|
||||
|
||||
Individual Cell Colors from `func Rich` take precedence over Column Colors
|
||||
|
||||
```go
|
||||
data := [][]string{
|
||||
[]string{"Test1Merge", "HelloCol2 - 1", "HelloCol3 - 1", "HelloCol4 - 1"},
|
||||
[]string{"Test1Merge", "HelloCol2 - 2", "HelloCol3 - 2", "HelloCol4 - 2"},
|
||||
[]string{"Test1Merge", "HelloCol2 - 3", "HelloCol3 - 3", "HelloCol4 - 3"},
|
||||
[]string{"Test2Merge", "HelloCol2 - 4", "HelloCol3 - 4", "HelloCol4 - 4"},
|
||||
[]string{"Test2Merge", "HelloCol2 - 5", "HelloCol3 - 5", "HelloCol4 - 5"},
|
||||
[]string{"Test2Merge", "HelloCol2 - 6", "HelloCol3 - 6", "HelloCol4 - 6"},
|
||||
[]string{"Test2Merge", "HelloCol2 - 7", "HelloCol3 - 7", "HelloCol4 - 7"},
|
||||
[]string{"Test3Merge", "HelloCol2 - 8", "HelloCol3 - 8", "HelloCol4 - 8"},
|
||||
[]string{"Test3Merge", "HelloCol2 - 9", "HelloCol3 - 9", "HelloCol4 - 9"},
|
||||
[]string{"Test3Merge", "HelloCol2 - 10", "HelloCol3 -10", "HelloCol4 - 10"},
|
||||
}
|
||||
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
table.SetHeader([]string{"Col1", "Col2", "Col3", "Col4"})
|
||||
table.SetFooter([]string{"", "", "Footer3", "Footer4"})
|
||||
table.EnableBorder(false)
|
||||
|
||||
table.SetHeaderColor(tablewriter.Colors{tablewriter.Bold, tablewriter.BgGreenColor},
|
||||
tablewriter.Colors{tablewriter.FgHiRedColor, tablewriter.Bold, tablewriter.BgBlackColor},
|
||||
tablewriter.Colors{tablewriter.BgRedColor, tablewriter.FgWhiteColor},
|
||||
tablewriter.Colors{tablewriter.BgCyanColor, tablewriter.FgWhiteColor})
|
||||
|
||||
table.SetColumnColor(tablewriter.Colors{tablewriter.Bold, tablewriter.FgHiBlackColor},
|
||||
tablewriter.Colors{tablewriter.Bold, tablewriter.FgHiRedColor},
|
||||
tablewriter.Colors{tablewriter.Bold, tablewriter.FgHiBlackColor},
|
||||
tablewriter.Colors{tablewriter.Bold, tablewriter.FgBlackColor})
|
||||
|
||||
table.SetFooterColor(tablewriter.Colors{}, tablewriter.Colors{},
|
||||
tablewriter.Colors{tablewriter.Bold},
|
||||
tablewriter.Colors{tablewriter.FgHiRedColor})
|
||||
|
||||
colorData1 := []string{"TestCOLOR1Merge", "HelloCol2 - COLOR1", "HelloCol3 - COLOR1", "HelloCol4 - COLOR1"}
|
||||
colorData2 := []string{"TestCOLOR2Merge", "HelloCol2 - COLOR2", "HelloCol3 - COLOR2", "HelloCol4 - COLOR2"}
|
||||
|
||||
for i, row := range data {
|
||||
if i == 4 {
|
||||
table.Rich(colorData1, []tablewriter.Colors{tablewriter.Colors{}, tablewriter.Colors{tablewriter.Normal, tablewriter.FgCyanColor}, tablewriter.Colors{tablewriter.Bold, tablewriter.FgWhiteColor}, tablewriter.Colors{}})
|
||||
table.Rich(colorData2, []tablewriter.Colors{tablewriter.Colors{tablewriter.Normal, tablewriter.FgMagentaColor}, tablewriter.Colors{}, tablewriter.Colors{tablewriter.Bold, tablewriter.BgRedColor}, tablewriter.Colors{tablewriter.FgHiGreenColor, tablewriter.Italic, tablewriter.BgHiCyanColor}})
|
||||
}
|
||||
table.Append(row)
|
||||
}
|
||||
|
||||
table.SetAutoMergeCells(true)
|
||||
table.Render()
|
||||
|
||||
```
|
||||
|
||||
##### Table cells with color Output
|
||||
|
||||

|
||||
|
||||
#### Example 9 - Set table caption
|
||||
|
||||
```go
|
||||
data := [][]string{
|
||||
[]string{"A", "The Good", "500"},
|
||||
[]string{"B", "The Very very Bad Man", "288"},
|
||||
[]string{"C", "The Ugly", "120"},
|
||||
[]string{"D", "The Gopher", "800"},
|
||||
}
|
||||
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
table.SetHeader([]string{"Name", "Sign", "Rating"})
|
||||
table.SetCaption(true, "Movie ratings.")
|
||||
|
||||
for _, v := range data {
|
||||
table.Append(v)
|
||||
}
|
||||
table.Render() // Send output
|
||||
```
|
||||
|
||||
Note: Caption text will wrap with total width of rendered table.
|
||||
|
||||
##### Output 9
|
||||
|
||||
```
|
||||
+------+-----------------------+--------+
|
||||
| NAME | SIGN | RATING |
|
||||
+------+-----------------------+--------+
|
||||
| A | The Good | 500 |
|
||||
| B | The Very very Bad Man | 288 |
|
||||
| C | The Ugly | 120 |
|
||||
| D | The Gopher | 800 |
|
||||
+------+-----------------------+--------+
|
||||
Movie ratings.
|
||||
```
|
||||
|
||||
#### Example 10 - Set NoWhiteSpace and TablePadding option
|
||||
|
||||
```go
|
||||
data := [][]string{
|
||||
{"node1.example.com", "Ready", "compute", "1.11"},
|
||||
{"node2.example.com", "Ready", "compute", "1.11"},
|
||||
{"node3.example.com", "Ready", "compute", "1.11"},
|
||||
{"node4.example.com", "NotReady", "compute", "1.11"},
|
||||
}
|
||||
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
table.SetHeader([]string{"Name", "Status", "Role", "Version"})
|
||||
table.SetAutoWrapText(false)
|
||||
table.SetAutoFormatHeaders(true)
|
||||
table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
|
||||
table.SetAlignment(tablewriter.ALIGN_LEFT)
|
||||
table.SetCenterSeparator("")
|
||||
table.SetColumnSeparator("")
|
||||
table.SetRowSeparator("")
|
||||
table.SetHeaderLine(false)
|
||||
table.EnableBorder(false)
|
||||
table.SetTablePadding("\t") // pad with tabs
|
||||
table.SetNoWhiteSpace(true)
|
||||
table.AppendBulk(data) // Add Bulk Data
|
||||
table.Render()
|
||||
```
|
||||
|
||||
##### Output 10
|
||||
|
||||
```
|
||||
NAME STATUS ROLE VERSION
|
||||
node1.example.com Ready compute 1.11
|
||||
node2.example.com Ready compute 1.11
|
||||
node3.example.com Ready compute 1.11
|
||||
node4.example.com NotReady compute 1.11
|
||||
```
|
||||
|
||||
#### Render table into a string
|
||||
|
||||
Instead of rendering the table to `io.Stdout` you can also render it into a string. Go 1.10 introduced the
|
||||
`strings.Builder` type which implements the `io.Writer` interface and can therefore be used for this task. Example:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"fmt"
|
||||
|
||||
"github.com/olekukonko/tablewriter"
|
||||
)
|
||||
|
||||
func main() {
|
||||
tableString := &strings.Builder{}
|
||||
table := tablewriter.NewWriter(tableString)
|
||||
|
||||
/*
|
||||
* Code to fill the table
|
||||
*/
|
||||
|
||||
table.Render()
|
||||
|
||||
fmt.Println(tableString.String())
|
||||
}
|
||||
```
|
||||
|
||||
#### TODO
|
||||
|
||||
- ~~Import Directly from CSV~~ - `done`
|
||||
- ~~Support for `SetFooter`~~ - `done`
|
||||
- ~~Support for `SetBorder`~~ - `done`
|
||||
- ~~Support table with uneven rows~~ - `done`
|
||||
- ~~Support custom alignment~~
|
||||
- General Improvement & Optimisation
|
||||
- `NewHTML` Parse table from HTML
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
recursive = true
|
||||
output_file = "all.txt"
|
||||
extensions = [".go"]
|
||||
exclude_dirs = [
|
||||
"_examples", "_readme", "_lab","_tmp","pkg","lab","cmd","test.txt","tmp",
|
||||
"_readme","pkg","renderer"
|
||||
]
|
||||
exclude_files = ["README.md","README_LEGACY.md","MIGRATION.md","test.hcl","csv.go"]
|
||||
use_gitignore = true
|
||||
detailed = true
|
||||
+1105
File diff suppressed because it is too large
Load Diff
+94
@@ -0,0 +1,94 @@
|
||||
package tablewriter
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// NewCSV Start A new table by importing from a CSV file
|
||||
// Takes io.Writer and csv File name
|
||||
func NewCSV(writer io.Writer, fileName string, hasHeader bool, opts ...Option) (*Table, error) {
|
||||
// Open the CSV file
|
||||
file, err := os.Open(fileName)
|
||||
if err != nil {
|
||||
// Log implicitly handled by NewTable if logger is configured via opts
|
||||
return nil, err // Return nil *Table on error
|
||||
}
|
||||
defer file.Close() // Ensure file is closed
|
||||
|
||||
// Create a CSV reader
|
||||
csvReader := csv.NewReader(file)
|
||||
|
||||
// Delegate to NewCSVReader, passing through the options
|
||||
return NewCSVReader(writer, csvReader, hasHeader, opts...)
|
||||
}
|
||||
|
||||
// NewCSVReader Start a New Table Writer with csv.Reader
|
||||
// This enables customisation such as reader.Comma = ';'
|
||||
// See http://golang.org/src/pkg/encoding/csv/reader.go?s=3213:3671#L94
|
||||
func NewCSVReader(writer io.Writer, csvReader *csv.Reader, hasHeader bool, opts ...Option) (*Table, error) {
|
||||
// Create a new table instance using the modern API and provided options.
|
||||
// Options configure the table's appearance and behavior (renderer, borders, etc.).
|
||||
t := NewTable(writer, opts...) // Logger setup happens here if WithLogger/WithDebug is passed
|
||||
|
||||
// Process header row if specified
|
||||
if hasHeader {
|
||||
headers, err := csvReader.Read()
|
||||
if err != nil {
|
||||
// Handle EOF specifically: means the CSV was empty or contained only an empty header line.
|
||||
if err == io.EOF {
|
||||
t.logger.Debug("NewCSVReader: CSV empty or only header found (EOF after header read attempt).")
|
||||
// Return the table configured by opts, but without data/header.
|
||||
// It's ready for Render() which will likely output nothing or just borders if configured.
|
||||
return t, nil
|
||||
}
|
||||
// Log other read errors
|
||||
t.logger.Errorf("NewCSVReader: Error reading CSV header: %v", err)
|
||||
return nil, err // Return nil *Table on critical read error
|
||||
}
|
||||
|
||||
// Check if the read header is genuinely empty (e.g., a blank line in the CSV)
|
||||
isEmptyHeader := true
|
||||
for _, h := range headers {
|
||||
if h != "" {
|
||||
isEmptyHeader = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !isEmptyHeader {
|
||||
t.Header(headers) // Use the Table method to set the header data
|
||||
t.logger.Debugf("NewCSVReader: Header set from CSV: %v", headers)
|
||||
} else {
|
||||
t.logger.Debug("NewCSVReader: Read an empty header line, skipping setting table header.")
|
||||
}
|
||||
}
|
||||
|
||||
// Process data rows
|
||||
rowCount := 0
|
||||
for {
|
||||
record, err := csvReader.Read()
|
||||
if err == io.EOF {
|
||||
break // Reached the end of the CSV data
|
||||
}
|
||||
if err != nil {
|
||||
// Log other read errors during data processing
|
||||
t.logger.Errorf("NewCSVReader: Error reading CSV record: %v", err)
|
||||
return nil, err // Return nil *Table on critical read error
|
||||
}
|
||||
|
||||
// Append the record to the table's internal buffer (for batch rendering).
|
||||
// The Table.Append method handles conversion and storage.
|
||||
if appendErr := t.Append(record); appendErr != nil {
|
||||
t.logger.Errorf("NewCSVReader: Error appending record #%d: %v", rowCount+1, appendErr)
|
||||
// Decide if append error is fatal. For now, let's treat it as fatal.
|
||||
return nil, appendErr
|
||||
}
|
||||
rowCount++
|
||||
}
|
||||
t.logger.Debugf("NewCSVReader: Finished reading CSV. Appended %d data rows.", rowCount)
|
||||
|
||||
// Return the configured and populated table instance, ready for Render() call.
|
||||
return t, nil
|
||||
}
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
package tablewriter
|
||||
|
||||
import (
|
||||
"github.com/mattn/go-runewidth"
|
||||
"github.com/olekukonko/tablewriter/pkg/twwidth"
|
||||
"github.com/olekukonko/tablewriter/tw"
|
||||
)
|
||||
|
||||
// WithBorders configures the table's border settings by updating the renderer's border configuration.
|
||||
// This function is deprecated and will be removed in a future version.
|
||||
//
|
||||
// Deprecated: Use [WithRendition] to configure border settings for renderers that support
|
||||
// [tw.Renditioning], or update the renderer's [tw.RenderConfig] directly via its Config() method.
|
||||
// This function has no effect if no renderer is set on the table.
|
||||
//
|
||||
// Example migration:
|
||||
//
|
||||
// // Old (deprecated)
|
||||
// table.Options(WithBorders(tw.Border{Top: true, Bottom: true}))
|
||||
// // New (recommended)
|
||||
// table.Options(WithRendition(tw.Rendition{Borders: tw.Border{Top: true, Bottom: true}}))
|
||||
//
|
||||
// Parameters:
|
||||
// - borders: The [tw.Border] configuration to apply to the renderer's borders.
|
||||
//
|
||||
// Returns:
|
||||
//
|
||||
// An [Option] that updates the renderer's border settings if a renderer is set.
|
||||
// Logs a debug message if debugging is enabled and a renderer is present.
|
||||
func WithBorders(borders tw.Border) Option {
|
||||
return func(target *Table) {
|
||||
if target.renderer != nil {
|
||||
cfg := target.renderer.Config()
|
||||
cfg.Borders = borders
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithBorders applied to Table: %+v", borders)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Behavior is an alias for [tw.Behavior] to configure table behavior settings.
|
||||
// This type is deprecated and will be removed in a future version.
|
||||
//
|
||||
// Deprecated: Use [tw.Behavior] directly to configure settings such as auto-hiding empty
|
||||
// columns, trimming spaces, or controlling header/footer visibility.
|
||||
//
|
||||
// Example migration:
|
||||
//
|
||||
// // Old (deprecated)
|
||||
// var b tablewriter.Behavior = tablewriter.Behavior{AutoHide: tw.On}
|
||||
// // New (recommended)
|
||||
// var b tw.Behavior = tw.Behavior{AutoHide: tw.On}
|
||||
type Behavior tw.Behavior
|
||||
|
||||
// Settings is an alias for [tw.Settings] to configure renderer settings.
|
||||
// This type is deprecated and will be removed in a future version.
|
||||
//
|
||||
// Deprecated: Use [tw.Settings] directly to configure renderer settings, such as
|
||||
// separators and line styles.
|
||||
//
|
||||
// Example migration:
|
||||
//
|
||||
// // Old (deprecated)
|
||||
// var s tablewriter.Settings = tablewriter.Settings{Separator: "|"}
|
||||
// // New (recommended)
|
||||
// var s tw.Settings = tw.Settings{Separator: "|"}
|
||||
type Settings tw.Settings
|
||||
|
||||
// WithRendererSettings updates the renderer's settings, such as separators and line styles.
|
||||
// This function is deprecated and will be removed in a future version.
|
||||
//
|
||||
// Deprecated: Use [WithRendition] to update renderer settings for renderers that implement
|
||||
// [tw.Renditioning], or configure the renderer's [tw.Settings] directly via its
|
||||
// [tw.Renderer.Config] method. This function has no effect if no renderer is set.
|
||||
//
|
||||
// Example migration:
|
||||
//
|
||||
// // Old (deprecated)
|
||||
// table.Options(WithRendererSettings(tw.Settings{Separator: "|"}))
|
||||
// // New (recommended)
|
||||
// table.Options(WithRendition(tw.Rendition{Settings: tw.Settings{Separator: "|"}}))
|
||||
//
|
||||
// Parameters:
|
||||
// - settings: The [tw.Settings] configuration to apply to the renderer.
|
||||
//
|
||||
// Returns:
|
||||
//
|
||||
// An [Option] that updates the renderer's settings if a renderer is set.
|
||||
// Logs a debug message if debugging is enabled and a renderer is present.
|
||||
func WithRendererSettings(settings tw.Settings) Option {
|
||||
return func(target *Table) {
|
||||
if target.renderer != nil {
|
||||
cfg := target.renderer.Config()
|
||||
cfg.Settings = settings
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithRendererSettings applied to Table: %+v", settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithAlignment sets the text alignment for footer cells within the formatting configuration.
|
||||
// This method is deprecated and will be removed in the next version.
|
||||
//
|
||||
// Deprecated: Use [FooterConfigBuilder.Alignment] with [AlignmentConfigBuilder.WithGlobal]
|
||||
// or [AlignmentConfigBuilder.WithPerColumn] to configure footer alignments.
|
||||
// Alternatively, apply a complete [tw.CellAlignment] configuration using
|
||||
// [WithFooterAlignmentConfig].
|
||||
//
|
||||
// Example migration:
|
||||
//
|
||||
// // Old (deprecated)
|
||||
// builder.Footer().Formatting().WithAlignment(tw.AlignRight)
|
||||
// // New (recommended)
|
||||
// builder.Footer().Alignment().WithGlobal(tw.AlignRight)
|
||||
// // Or
|
||||
// table.Options(WithFooterAlignmentConfig(tw.CellAlignment{Global: tw.AlignRight}))
|
||||
//
|
||||
// Parameters:
|
||||
// - align: The [tw.Align] value to set for footer cells. Valid values are
|
||||
// [tw.AlignLeft], [tw.AlignRight], [tw.AlignCenter], and [tw.AlignNone].
|
||||
// Invalid alignments are ignored.
|
||||
//
|
||||
// Returns:
|
||||
//
|
||||
// The [FooterFormattingBuilder] instance for method chaining.
|
||||
func (ff *FooterFormattingBuilder) WithAlignment(align tw.Align) *FooterFormattingBuilder {
|
||||
if align != tw.AlignLeft && align != tw.AlignRight && align != tw.AlignCenter && align != tw.AlignNone {
|
||||
return ff
|
||||
}
|
||||
ff.config.Alignment = align
|
||||
return ff
|
||||
}
|
||||
|
||||
// WithAlignment sets the text alignment for header cells within the formatting configuration.
|
||||
// This method is deprecated and will be removed in the next version.
|
||||
//
|
||||
// Deprecated: Use [HeaderConfigBuilder.Alignment] with [AlignmentConfigBuilder.WithGlobal]
|
||||
// or [AlignmentConfigBuilder.WithPerColumn] to configure header alignments.
|
||||
// Alternatively, apply a complete [tw.CellAlignment] configuration using
|
||||
// [WithHeaderAlignmentConfig].
|
||||
//
|
||||
// Example migration:
|
||||
//
|
||||
// // Old (deprecated)
|
||||
// builder.Header().Formatting().WithAlignment(tw.AlignCenter)
|
||||
// // New (recommended)
|
||||
// builder.Header().Alignment().WithGlobal(tw.AlignCenter)
|
||||
// // Or
|
||||
// table.Options(WithHeaderAlignmentConfig(tw.CellAlignment{Global: tw.AlignCenter}))
|
||||
//
|
||||
// Parameters:
|
||||
// - align: The [tw.Align] value to set for header cells. Valid values are
|
||||
// [tw.AlignLeft], [tw.AlignRight], [tw.AlignCenter], and [tw.AlignNone].
|
||||
// Invalid alignments are ignored.
|
||||
//
|
||||
// Returns:
|
||||
//
|
||||
// The [HeaderFormattingBuilder] instance for method chaining.
|
||||
func (hf *HeaderFormattingBuilder) WithAlignment(align tw.Align) *HeaderFormattingBuilder {
|
||||
if align != tw.AlignLeft && align != tw.AlignRight && align != tw.AlignCenter && align != tw.AlignNone {
|
||||
return hf
|
||||
}
|
||||
hf.config.Alignment = align
|
||||
return hf
|
||||
}
|
||||
|
||||
// WithAlignment sets the text alignment for row cells within the formatting configuration.
|
||||
// This method is deprecated and will be removed in the next version.
|
||||
//
|
||||
// Deprecated: Use [RowConfigBuilder.Alignment] with [AlignmentConfigBuilder.WithGlobal]
|
||||
// or [AlignmentConfigBuilder.WithPerColumn] to configure row alignments.
|
||||
// Alternatively, apply a complete [tw.CellAlignment] configuration using
|
||||
// [WithRowAlignmentConfig].
|
||||
//
|
||||
// Example migration:
|
||||
//
|
||||
// // Old (deprecated)
|
||||
// builder.Row().Formatting().WithAlignment(tw.AlignLeft)
|
||||
// // New (recommended)
|
||||
// builder.Row().Alignment().WithGlobal(tw.AlignLeft)
|
||||
// // Or
|
||||
// table.Options(WithRowAlignmentConfig(tw.CellAlignment{Global: tw.AlignLeft}))
|
||||
//
|
||||
// Parameters:
|
||||
// - align: The [tw.Align] value to set for row cells. Valid values are
|
||||
// [tw.AlignLeft], [tw.AlignRight], [tw.AlignCenter], and [tw.AlignNone].
|
||||
// Invalid alignments are ignored.
|
||||
//
|
||||
// Returns:
|
||||
//
|
||||
// The [RowFormattingBuilder] instance for method chaining.
|
||||
func (rf *RowFormattingBuilder) WithAlignment(align tw.Align) *RowFormattingBuilder {
|
||||
if align != tw.AlignLeft && align != tw.AlignRight && align != tw.AlignCenter && align != tw.AlignNone {
|
||||
return rf
|
||||
}
|
||||
rf.config.Alignment = align
|
||||
return rf
|
||||
}
|
||||
|
||||
// WithTableMax sets the maximum width of the entire table in characters.
|
||||
// Negative values are ignored, and the change is logged if debugging is enabled.
|
||||
// The width constrains the table's rendering, potentially causing text wrapping or truncation
|
||||
// based on the configuration's wrapping settings (e.g., tw.WrapTruncate).
|
||||
// If debug logging is enabled via WithDebug(true), the applied width is logged.
|
||||
//
|
||||
// Deprecated: Use WithMaxWidth instead, which provides the same functionality with a clearer name
|
||||
// and consistent naming across the package. For example:
|
||||
//
|
||||
// tablewriter.NewTable(os.Stdout, tablewriter.WithMaxWidth(80))
|
||||
func WithTableMax(width int) Option {
|
||||
return func(target *Table) {
|
||||
if width < 0 {
|
||||
return
|
||||
}
|
||||
target.config.MaxWidth = width
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithTableMax applied to Table: %v", width)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deprecated: use WithEastAsian instead.
|
||||
// WithCondition provides a way to set a custom global runewidth.Condition
|
||||
// that will be used for all subsequent display width calculations by the twwidth (twdw) package.
|
||||
//
|
||||
// The runewidth.Condition object allows for more fine-grained control over how rune widths
|
||||
// are determined, beyond just toggling EastAsianWidth. This could include settings for
|
||||
// ambiguous width characters or other future properties of runewidth.Condition.
|
||||
func WithCondition(cond *runewidth.Condition) Option {
|
||||
return func(target *Table) {
|
||||
twwidth.SetCondition(cond)
|
||||
}
|
||||
}
|
||||
+991
@@ -0,0 +1,991 @@
|
||||
package tablewriter
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/olekukonko/ll"
|
||||
"github.com/olekukonko/tablewriter/pkg/twcache"
|
||||
"github.com/olekukonko/tablewriter/pkg/twwidth"
|
||||
"github.com/olekukonko/tablewriter/tw"
|
||||
)
|
||||
|
||||
// Option defines a function type for configuring a Table instance.
|
||||
type Option func(target *Table)
|
||||
|
||||
// WithAutoHide enables or disables automatic hiding of columns with empty data rows.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithAutoHide(state tw.State) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Behavior.AutoHide = state
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithAutoHide applied to Table: %v", state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithColumnMax sets a global maximum column width for the table in streaming mode.
|
||||
// Negative values are ignored, and the change is logged if debugging is enabled.
|
||||
func WithColumnMax(width int) Option {
|
||||
return func(target *Table) {
|
||||
if width < 0 {
|
||||
return
|
||||
}
|
||||
target.config.Widths.Global = width
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithColumnMax applied to Table: %v", width)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithMaxWidth sets a global maximum table width for the table.
|
||||
// Negative values are ignored, and the change is logged if debugging is enabled.
|
||||
func WithMaxWidth(width int) Option {
|
||||
return func(target *Table) {
|
||||
if width < 0 {
|
||||
return
|
||||
}
|
||||
target.config.MaxWidth = width
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithTableMax applied to Table: %v", width)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithWidths sets per-column widths for the table.
|
||||
// Negative widths are removed, and the change is logged if debugging is enabled.
|
||||
func WithWidths(width tw.CellWidth) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Widths = width
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithColumnWidths applied to Table: %v", width)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithColumnWidths sets per-column widths for the table.
|
||||
// Negative widths are removed, and the change is logged if debugging is enabled.
|
||||
func WithColumnWidths(widths tw.Mapper[int, int]) Option {
|
||||
return func(target *Table) {
|
||||
for k, v := range widths {
|
||||
if v < 0 {
|
||||
delete(widths, k)
|
||||
}
|
||||
}
|
||||
target.config.Widths.PerColumn = widths
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithColumnWidths applied to Table: %v", widths)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithConfig applies a custom configuration to the table by merging it with the default configuration.
|
||||
func WithConfig(cfg Config) Option {
|
||||
return func(target *Table) {
|
||||
target.config = mergeConfig(defaultConfig(), cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// WithDebug enables or disables debug logging and adjusts the logger level accordingly.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithDebug(debug bool) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Debug = debug
|
||||
}
|
||||
}
|
||||
|
||||
// WithFooter sets the table footers by calling the Footer method.
|
||||
func WithFooter(footers []string) Option {
|
||||
return func(target *Table) {
|
||||
target.Footer(footers)
|
||||
}
|
||||
}
|
||||
|
||||
// WithFooterConfig applies a full footer configuration to the table.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithFooterConfig(config tw.CellConfig) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Footer = config
|
||||
if target.logger != nil {
|
||||
target.logger.Debug("Option: WithFooterConfig applied to Table.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithFooterAlignmentConfig applies a footer alignment configuration to the table.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithFooterAlignmentConfig(alignment tw.CellAlignment) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Footer.Alignment = alignment
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithFooterAlignmentConfig applied to Table: %+v", alignment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deprecated: Use a ConfigBuilder with .Footer().CellMerging().WithMode(...) instead.
|
||||
// This option will be removed in a future version.
|
||||
func WithFooterMergeMode(mergeMode int) Option {
|
||||
return func(target *Table) {
|
||||
if mergeMode < tw.MergeNone || mergeMode > tw.MergeHierarchical {
|
||||
return
|
||||
}
|
||||
target.config.Footer.Merging.Mode = mergeMode
|
||||
target.config.Footer.Formatting.MergeMode = mergeMode
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithFooterMergeMode applied to Table: %v", mergeMode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithFooterAutoWrap sets the wrapping behavior for footer cells.
|
||||
// Invalid wrap modes are ignored, and the change is logged if debugging is enabled.
|
||||
func WithFooterAutoWrap(wrap int) Option {
|
||||
return func(target *Table) {
|
||||
if wrap < tw.WrapNone || wrap > tw.WrapBreak {
|
||||
return
|
||||
}
|
||||
target.config.Footer.Formatting.AutoWrap = wrap
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithFooterAutoWrap applied to Table: %v", wrap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithFooterFilter sets the filter configuration for footer cells.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithFooterFilter(filter tw.CellFilter) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Footer.Filter = filter
|
||||
if target.logger != nil {
|
||||
target.logger.Debug("Option: WithFooterFilter applied to Table.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithFooterCallbacks sets the callback configuration for footer cells.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithFooterCallbacks(callbacks tw.CellCallbacks) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Footer.Callbacks = callbacks
|
||||
if target.logger != nil {
|
||||
target.logger.Debug("Option: WithFooterCallbacks applied to Table.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithFooterPaddingPerColumn sets per-column padding for footer cells.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithFooterPaddingPerColumn(padding []tw.Padding) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Footer.Padding.PerColumn = padding
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithFooterPaddingPerColumn applied to Table: %+v", padding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithFooterMaxWidth sets the maximum content width for footer cells.
|
||||
// Negative values are ignored, and the change is logged if debugging is enabled.
|
||||
func WithFooterMaxWidth(maxWidth int) Option {
|
||||
return func(target *Table) {
|
||||
if maxWidth < 0 {
|
||||
return
|
||||
}
|
||||
target.config.Footer.ColMaxWidths.Global = maxWidth
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithFooterMaxWidth applied to Table: %v", maxWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithHeader sets the table headers by calling the Header method.
|
||||
func WithHeader(headers []string) Option {
|
||||
return func(target *Table) {
|
||||
target.Header(headers)
|
||||
}
|
||||
}
|
||||
|
||||
// WithHeaderAlignment sets the text alignment for header cells.
|
||||
// Invalid alignments are ignored, and the change is logged if debugging is enabled.
|
||||
func WithHeaderAlignment(align tw.Align) Option {
|
||||
return func(target *Table) {
|
||||
if align != tw.AlignLeft && align != tw.AlignRight && align != tw.AlignCenter && align != tw.AlignNone {
|
||||
return
|
||||
}
|
||||
target.config.Header.Alignment.Global = align
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithHeaderAlignment applied to Table: %v", align)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithHeaderAutoWrap sets the wrapping behavior for header cells.
|
||||
// Invalid wrap modes are ignored, and the change is logged if debugging is enabled.
|
||||
func WithHeaderAutoWrap(wrap int) Option {
|
||||
return func(target *Table) {
|
||||
if wrap < tw.WrapNone || wrap > tw.WrapBreak {
|
||||
return
|
||||
}
|
||||
target.config.Header.Formatting.AutoWrap = wrap
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithHeaderAutoWrap applied to Table: %v", wrap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deprecated: Use a ConfigBuilder with .Header().CellMerging().WithMode(...) instead.
|
||||
// This option will be removed in a future version.
|
||||
func WithHeaderMergeMode(mergeMode int) Option {
|
||||
return func(target *Table) {
|
||||
if mergeMode < tw.MergeNone || mergeMode > tw.MergeHierarchical {
|
||||
return
|
||||
}
|
||||
target.config.Header.Merging.Mode = mergeMode
|
||||
target.config.Header.Formatting.MergeMode = mergeMode
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithHeaderMergeMode applied to Table: %v", mergeMode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithHeaderFilter sets the filter configuration for header cells.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithHeaderFilter(filter tw.CellFilter) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Header.Filter = filter
|
||||
if target.logger != nil {
|
||||
target.logger.Debug("Option: WithHeaderFilter applied to Table.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithHeaderCallbacks sets the callback configuration for header cells.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithHeaderCallbacks(callbacks tw.CellCallbacks) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Header.Callbacks = callbacks
|
||||
if target.logger != nil {
|
||||
target.logger.Debug("Option: WithHeaderCallbacks applied to Table.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithHeaderPaddingPerColumn sets per-column padding for header cells.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithHeaderPaddingPerColumn(padding []tw.Padding) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Header.Padding.PerColumn = padding
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithHeaderPaddingPerColumn applied to Table: %+v", padding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithHeaderMaxWidth sets the maximum content width for header cells.
|
||||
// Negative values are ignored, and the change is logged if debugging is enabled.
|
||||
func WithHeaderMaxWidth(maxWidth int) Option {
|
||||
return func(target *Table) {
|
||||
if maxWidth < 0 {
|
||||
return
|
||||
}
|
||||
target.config.Header.ColMaxWidths.Global = maxWidth
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithHeaderMaxWidth applied to Table: %v", maxWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithRowAlignment sets the text alignment for row cells.
|
||||
// Invalid alignments are ignored, and the change is logged if debugging is enabled.
|
||||
func WithRowAlignment(align tw.Align) Option {
|
||||
return func(target *Table) {
|
||||
if err := align.Validate(); err != nil {
|
||||
return
|
||||
}
|
||||
target.config.Row.Alignment.Global = align
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithRowAlignment applied to Table: %v", align)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithRowAutoWrap sets the wrapping behavior for row cells.
|
||||
// Invalid wrap modes are ignored, and the change is logged if debugging is enabled.
|
||||
func WithRowAutoWrap(wrap int) Option {
|
||||
return func(target *Table) {
|
||||
if wrap < tw.WrapNone || wrap > tw.WrapBreak {
|
||||
return
|
||||
}
|
||||
target.config.Row.Formatting.AutoWrap = wrap
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithRowAutoWrap applied to Table: %v", wrap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deprecated: Use a ConfigBuilder with .Row().CellMerging().WithMode(...) instead.
|
||||
// This option will be removed in a future version.
|
||||
func WithRowMergeMode(mergeMode int) Option {
|
||||
return func(target *Table) {
|
||||
if mergeMode < tw.MergeNone || mergeMode > tw.MergeHierarchical {
|
||||
return
|
||||
}
|
||||
target.config.Row.Merging.Mode = mergeMode
|
||||
target.config.Row.Formatting.MergeMode = mergeMode
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithRowMergeMode applied to Table: %v", mergeMode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithRowFilter sets the filter configuration for row cells.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithRowFilter(filter tw.CellFilter) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Row.Filter = filter
|
||||
if target.logger != nil {
|
||||
target.logger.Debug("Option: WithRowFilter applied to Table.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithRowCallbacks sets the callback configuration for row cells.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithRowCallbacks(callbacks tw.CellCallbacks) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Row.Callbacks = callbacks
|
||||
if target.logger != nil {
|
||||
target.logger.Debug("Option: WithRowCallbacks applied to Table.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithRowPaddingPerColumn sets per-column padding for row cells.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithRowPaddingPerColumn(padding []tw.Padding) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Row.Padding.PerColumn = padding
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithRowPaddingPerColumn applied to Table: %+v", padding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithHeaderAlignmentConfig applies a header alignment configuration to the table.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithHeaderAlignmentConfig(alignment tw.CellAlignment) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Header.Alignment = alignment
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithHeaderAlignmentConfig applied to Table: %+v", alignment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithHeaderConfig applies a full header configuration to the table.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithHeaderConfig(config tw.CellConfig) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Header = config
|
||||
if target.logger != nil {
|
||||
target.logger.Debug("Option: WithHeaderConfig applied to Table.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithLogger sets a custom logger for the table and updates the renderer if present.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithLogger(logger *ll.Logger) Option {
|
||||
return func(target *Table) {
|
||||
target.logger = logger
|
||||
if target.logger != nil {
|
||||
target.logger.Debug("Option: WithLogger applied to Table.")
|
||||
if target.renderer != nil {
|
||||
target.renderer.Logger(target.logger)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithRenderer sets a custom renderer for the table and attaches the logger if present.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithRenderer(f tw.Renderer) Option {
|
||||
return func(target *Table) {
|
||||
target.renderer = f
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithRenderer applied to Table: %T", f)
|
||||
f.Logger(target.logger)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithRowConfig applies a full row configuration to the table.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithRowConfig(config tw.CellConfig) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Row = config
|
||||
if target.logger != nil {
|
||||
target.logger.Debug("Option: WithRowConfig applied to Table.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithRowAlignmentConfig applies a row alignment configuration to the table.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithRowAlignmentConfig(alignment tw.CellAlignment) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Row.Alignment = alignment
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithRowAlignmentConfig applied to Table: %+v", alignment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithRowMaxWidth sets the maximum content width for row cells.
|
||||
// Negative values are ignored, and the change is logged if debugging is enabled.
|
||||
func WithRowMaxWidth(maxWidth int) Option {
|
||||
return func(target *Table) {
|
||||
if maxWidth < 0 {
|
||||
return
|
||||
}
|
||||
target.config.Row.ColMaxWidths.Global = maxWidth
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithRowMaxWidth applied to Table: %v", maxWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithStreaming applies a streaming configuration to the table by merging it with the existing configuration.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithStreaming(c tw.StreamConfig) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Stream = mergeStreamConfig(target.config.Stream, c)
|
||||
if target.logger != nil {
|
||||
target.logger.Debug("Option: WithStreaming applied to Table.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithStringer sets a custom stringer function for converting row data and clears the stringer cache.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithStringer(stringer interface{}) Option {
|
||||
return func(t *Table) {
|
||||
t.stringer = stringer
|
||||
t.stringerCache = twcache.NewLRU[reflect.Type, reflect.Value](tw.DefaultCacheStringCapacity)
|
||||
if t.logger != nil {
|
||||
t.logger.Debug("Stringer updated, cache cleared")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithStringerCache enables the default LRU caching for the stringer function.
|
||||
// It initializes the cache with a default capacity if one does not already exist.
|
||||
func WithStringerCache() Option {
|
||||
return func(t *Table) {
|
||||
// Initialize default cache if strictly necessary (nil),
|
||||
// or if you want to ensure the default implementation is used.
|
||||
if t.stringerCache == nil {
|
||||
// NewLRU returns (Instance, error). We ignore the error here assuming capacity > 0.
|
||||
cache := twcache.NewLRU[reflect.Type, reflect.Value](tw.DefaultCacheStringCapacity)
|
||||
t.stringerCache = cache
|
||||
}
|
||||
|
||||
if t.logger != nil {
|
||||
t.logger.Debug("Option: WithStringerCache enabled (Default LRU)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithStringerCacheCustom enables caching for the stringer function using a specific implementation.
|
||||
// Passing nil disables caching entirely.
|
||||
func WithStringerCacheCustom(cache twcache.Cache[reflect.Type, reflect.Value]) Option {
|
||||
return func(t *Table) {
|
||||
if cache == nil {
|
||||
t.stringerCache = nil
|
||||
if t.logger != nil {
|
||||
t.logger.Debug("Option: WithStringerCacheCustom called with nil (Caching Disabled)")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Set the custom cache and enable the flag
|
||||
t.stringerCache = cache
|
||||
|
||||
if t.logger != nil {
|
||||
t.logger.Debug("Option: WithStringerCacheCustom enabled")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithTrimSpace sets whether leading and trailing spaces are automatically trimmed.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithTrimSpace(state tw.State) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Behavior.TrimSpace = state
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithTrimSpace applied to Table: %v", state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithTrimTab sets whether leading and trailing tab characters are automatically trimmed.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithTrimTab(state tw.State) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Behavior.TrimTab = state
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithTrimTab applied to Table: %v", state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithTrimLine sets whether empty visual lines within a cell are trimmed.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithTrimLine(state tw.State) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Behavior.TrimLine = state
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithTrimLine applied to Table: %v", state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithHeaderAutoFormat enables or disables automatic formatting for header cells.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithHeaderAutoFormat(state tw.State) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Header.Formatting.AutoFormat = state
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithHeaderAutoFormat applied to Table: %v", state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithFooterAutoFormat enables or disables automatic formatting for footer cells.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithFooterAutoFormat(state tw.State) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Footer.Formatting.AutoFormat = state
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithFooterAutoFormat applied to Table: %v", state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithRowAutoFormat enables or disables automatic formatting for row cells.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithRowAutoFormat(state tw.State) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Row.Formatting.AutoFormat = state
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithRowAutoFormat applied to Table: %v", state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithHeaderControl sets the control behavior for the table header.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithHeaderControl(control tw.Control) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Behavior.Header = control
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithHeaderControl applied to Table: %v", control)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithFooterControl sets the control behavior for the table footer.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithFooterControl(control tw.Control) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Behavior.Footer = control
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithFooterControl applied to Table: %v", control)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithAlignment sets the default column alignment for the header, rows, and footer.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithAlignment(alignment tw.Alignment) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Header.Alignment.PerColumn = alignment
|
||||
target.config.Row.Alignment.PerColumn = alignment
|
||||
target.config.Footer.Alignment.PerColumn = alignment
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithAlignment applied to Table: %+v", alignment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithBehavior applies a behavior configuration to the table.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithBehavior(behavior tw.Behavior) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Behavior = behavior
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithBehavior applied to Table: %+v", behavior)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithPadding sets the global padding for the header, rows, and footer.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithPadding(padding tw.Padding) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Header.Padding.Global = padding
|
||||
target.config.Row.Padding.Global = padding
|
||||
target.config.Footer.Padding.Global = padding
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithPadding applied to Table: %+v", padding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithRendition allows updating the active renderer's rendition configuration
|
||||
// by merging the provided rendition.
|
||||
// If the renderer does not implement tw.Renditioning, a warning is logged.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithRendition(rendition tw.Rendition) Option {
|
||||
return func(target *Table) {
|
||||
if target.renderer == nil {
|
||||
if target.logger != nil {
|
||||
target.logger.Warn("Option: WithRendition: No renderer set on table.")
|
||||
}
|
||||
return
|
||||
}
|
||||
if ru, ok := target.renderer.(tw.Renditioning); ok {
|
||||
ru.Rendition(rendition)
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithRendition: Applied to renderer via Renditioning.SetRendition(): %+v", rendition)
|
||||
}
|
||||
} else if target.logger != nil {
|
||||
target.logger.Warnf("Option: WithRendition: Current renderer type %T does not implement tw.Renditioning. Rendition may not be applied as expected.", target.renderer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithEastAsian configures the global East Asian width calculation setting.
|
||||
// - state=tw.On: Enables East Asian width calculations. CJK and ambiguous characters
|
||||
// are typically measured as double width.
|
||||
// - state=tw.Off: Disables East Asian width calculations. Characters are generally
|
||||
// measured as single width, subject to Unicode standards.
|
||||
//
|
||||
// This setting affects all subsequent display width calculations using the twdw package.
|
||||
func WithEastAsian(state tw.State) Option {
|
||||
return func(target *Table) {
|
||||
if state.Enabled() {
|
||||
twwidth.SetEastAsian(true)
|
||||
}
|
||||
if state.Disabled() {
|
||||
twwidth.SetEastAsian(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithSymbols sets the symbols used for drawing table borders and separators.
|
||||
// The symbols are applied to the table's renderer configuration, if a renderer is set.
|
||||
// If no renderer is set (target.renderer is nil), this option has no effect. .
|
||||
func WithSymbols(symbols tw.Symbols) Option {
|
||||
return func(target *Table) {
|
||||
if target.renderer != nil {
|
||||
cfg := target.renderer.Config()
|
||||
cfg.Symbols = symbols
|
||||
|
||||
if ru, ok := target.renderer.(tw.Renditioning); ok {
|
||||
ru.Rendition(cfg)
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithRendition: Applied to renderer via Renditioning.SetRendition(): %+v", cfg)
|
||||
}
|
||||
} else if target.logger != nil {
|
||||
target.logger.Warnf("Option: WithRendition: Current renderer type %T does not implement tw.Renditioning. Rendition may not be applied as expected.", target.renderer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithCounters enables line counting by wrapping the table's writer.
|
||||
// If a custom counter (that implements tw.Counter) is provided, it will be used.
|
||||
// If the provided counter is nil, a default tw.LineCounter will be used.
|
||||
// The final count can be retrieved via the table.Lines() method after Render() is called.
|
||||
func WithCounters(counters ...tw.Counter) Option {
|
||||
return func(target *Table) {
|
||||
// Iterate through the provided counters and add any non-nil ones.
|
||||
for _, c := range counters {
|
||||
if c != nil {
|
||||
target.counters = append(target.counters, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithLineCounter enables the default line counter.
|
||||
// A new instance of tw.LineCounter is added to the table's list of counters.
|
||||
// The total count can be retrieved via the table.Lines() method after Render() is called.
|
||||
func WithLineCounter() Option {
|
||||
return func(target *Table) {
|
||||
// Important: Create a new instance so tables don't share counters.
|
||||
target.counters = append(target.counters, &tw.LineCounter{})
|
||||
}
|
||||
}
|
||||
|
||||
// defaultConfig returns a default Config with sensible settings for headers, rows, footers, and behavior.
|
||||
func defaultConfig() Config {
|
||||
return Config{
|
||||
MaxWidth: 0,
|
||||
Header: tw.CellConfig{
|
||||
Formatting: tw.CellFormatting{
|
||||
AutoWrap: tw.WrapTruncate,
|
||||
AutoFormat: tw.On,
|
||||
MergeMode: tw.MergeNone,
|
||||
},
|
||||
Merging: tw.CellMerging{
|
||||
Mode: tw.MergeNone,
|
||||
},
|
||||
Padding: tw.CellPadding{
|
||||
Global: tw.PaddingDefault,
|
||||
},
|
||||
Alignment: tw.CellAlignment{
|
||||
Global: tw.AlignCenter,
|
||||
PerColumn: []tw.Align{},
|
||||
},
|
||||
},
|
||||
Row: tw.CellConfig{
|
||||
Formatting: tw.CellFormatting{
|
||||
AutoWrap: tw.WrapNormal,
|
||||
AutoFormat: tw.Off,
|
||||
MergeMode: tw.MergeNone,
|
||||
},
|
||||
Merging: tw.CellMerging{
|
||||
Mode: tw.MergeNone,
|
||||
},
|
||||
Padding: tw.CellPadding{
|
||||
Global: tw.PaddingDefault,
|
||||
},
|
||||
Alignment: tw.CellAlignment{
|
||||
Global: tw.AlignLeft,
|
||||
PerColumn: []tw.Align{},
|
||||
},
|
||||
},
|
||||
Footer: tw.CellConfig{
|
||||
Formatting: tw.CellFormatting{
|
||||
AutoWrap: tw.WrapNormal,
|
||||
AutoFormat: tw.Off,
|
||||
MergeMode: tw.MergeNone,
|
||||
},
|
||||
Merging: tw.CellMerging{
|
||||
Mode: tw.MergeNone,
|
||||
},
|
||||
Padding: tw.CellPadding{
|
||||
Global: tw.PaddingDefault,
|
||||
},
|
||||
Alignment: tw.CellAlignment{
|
||||
Global: tw.AlignRight,
|
||||
PerColumn: []tw.Align{},
|
||||
},
|
||||
},
|
||||
Stream: tw.StreamConfig{
|
||||
Enable: false,
|
||||
StrictColumns: false,
|
||||
},
|
||||
Debug: false,
|
||||
Behavior: tw.Behavior{
|
||||
AutoHide: tw.Off,
|
||||
TrimSpace: tw.On,
|
||||
TrimTab: tw.On,
|
||||
TrimLine: tw.On,
|
||||
Structs: tw.Struct{
|
||||
AutoHeader: tw.Off,
|
||||
Tags: []string{"json", "db"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// mergeCellConfig merges a source CellConfig into a destination CellConfig, prioritizing non-default source values.
|
||||
// It handles deep merging for complex fields like padding and callbacks.
|
||||
func mergeCellConfig(dst, src tw.CellConfig) tw.CellConfig {
|
||||
if src.Formatting.Alignment != tw.Empty {
|
||||
dst.Formatting.Alignment = src.Formatting.Alignment
|
||||
}
|
||||
|
||||
if src.Formatting.AutoWrap != 0 {
|
||||
dst.Formatting.AutoWrap = src.Formatting.AutoWrap
|
||||
}
|
||||
if src.ColMaxWidths.Global != 0 {
|
||||
dst.ColMaxWidths.Global = src.ColMaxWidths.Global
|
||||
}
|
||||
|
||||
// Handle merging of the new CellMerging struct and the deprecated MergeMode
|
||||
if src.Merging.Mode != 0 {
|
||||
dst.Merging.Mode = src.Merging.Mode
|
||||
dst.Formatting.MergeMode = src.Merging.Mode
|
||||
} else if src.Formatting.MergeMode != 0 {
|
||||
dst.Merging.Mode = src.Formatting.MergeMode
|
||||
dst.Formatting.MergeMode = src.Formatting.MergeMode
|
||||
}
|
||||
|
||||
if src.Merging.ByColumnIndex != nil {
|
||||
dst.Merging.ByColumnIndex = src.Merging.ByColumnIndex.Clone()
|
||||
}
|
||||
|
||||
dst.Formatting.AutoFormat = src.Formatting.AutoFormat
|
||||
|
||||
if src.Padding.Global.Paddable() {
|
||||
dst.Padding.Global = src.Padding.Global
|
||||
}
|
||||
|
||||
if len(src.Padding.PerColumn) > 0 {
|
||||
if dst.Padding.PerColumn == nil {
|
||||
dst.Padding.PerColumn = make([]tw.Padding, len(src.Padding.PerColumn))
|
||||
} else if len(src.Padding.PerColumn) > len(dst.Padding.PerColumn) {
|
||||
dst.Padding.PerColumn = append(dst.Padding.PerColumn, make([]tw.Padding, len(src.Padding.PerColumn)-len(dst.Padding.PerColumn))...)
|
||||
}
|
||||
for i, pad := range src.Padding.PerColumn {
|
||||
if pad.Paddable() {
|
||||
dst.Padding.PerColumn[i] = pad
|
||||
}
|
||||
}
|
||||
}
|
||||
if src.Callbacks.Global != nil {
|
||||
dst.Callbacks.Global = src.Callbacks.Global
|
||||
}
|
||||
if len(src.Callbacks.PerColumn) > 0 {
|
||||
if dst.Callbacks.PerColumn == nil {
|
||||
dst.Callbacks.PerColumn = make([]func(), len(src.Callbacks.PerColumn))
|
||||
} else if len(src.Callbacks.PerColumn) > len(dst.Callbacks.PerColumn) {
|
||||
dst.Callbacks.PerColumn = append(dst.Callbacks.PerColumn, make([]func(), len(src.Callbacks.PerColumn)-len(dst.Callbacks.PerColumn))...)
|
||||
}
|
||||
for i, cb := range src.Callbacks.PerColumn {
|
||||
if cb != nil {
|
||||
dst.Callbacks.PerColumn[i] = cb
|
||||
}
|
||||
}
|
||||
}
|
||||
if src.Filter.Global != nil {
|
||||
dst.Filter.Global = src.Filter.Global
|
||||
}
|
||||
if len(src.Filter.PerColumn) > 0 {
|
||||
if dst.Filter.PerColumn == nil {
|
||||
dst.Filter.PerColumn = make([]func(string) string, len(src.Filter.PerColumn))
|
||||
} else if len(src.Filter.PerColumn) > len(dst.Filter.PerColumn) {
|
||||
dst.Filter.PerColumn = append(dst.Filter.PerColumn, make([]func(string) string, len(src.Filter.PerColumn)-len(dst.Filter.PerColumn))...)
|
||||
}
|
||||
for i, filter := range src.Filter.PerColumn {
|
||||
if filter != nil {
|
||||
dst.Filter.PerColumn[i] = filter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge Alignment
|
||||
if src.Alignment.Global != tw.Empty {
|
||||
dst.Alignment.Global = src.Alignment.Global
|
||||
}
|
||||
|
||||
if len(src.Alignment.PerColumn) > 0 {
|
||||
if dst.Alignment.PerColumn == nil {
|
||||
dst.Alignment.PerColumn = make([]tw.Align, len(src.Alignment.PerColumn))
|
||||
} else if len(src.Alignment.PerColumn) > len(dst.Alignment.PerColumn) {
|
||||
dst.Alignment.PerColumn = append(dst.Alignment.PerColumn, make([]tw.Align, len(src.Alignment.PerColumn)-len(dst.Alignment.PerColumn))...)
|
||||
}
|
||||
for i, align := range src.Alignment.PerColumn {
|
||||
if align != tw.Skip {
|
||||
dst.Alignment.PerColumn[i] = align
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(src.ColumnAligns) > 0 {
|
||||
if dst.ColumnAligns == nil {
|
||||
dst.ColumnAligns = make([]tw.Align, len(src.ColumnAligns))
|
||||
} else if len(src.ColumnAligns) > len(dst.ColumnAligns) {
|
||||
dst.ColumnAligns = append(dst.ColumnAligns, make([]tw.Align, len(src.ColumnAligns)-len(dst.ColumnAligns))...)
|
||||
}
|
||||
for i, align := range src.ColumnAligns {
|
||||
if align != tw.Skip {
|
||||
dst.ColumnAligns[i] = align
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(src.ColMaxWidths.PerColumn) > 0 {
|
||||
if dst.ColMaxWidths.PerColumn == nil {
|
||||
dst.ColMaxWidths.PerColumn = make(map[int]int)
|
||||
}
|
||||
for k, v := range src.ColMaxWidths.PerColumn {
|
||||
if v != 0 {
|
||||
dst.ColMaxWidths.PerColumn[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// mergeConfig merges a source Config into a destination Config, prioritizing non-default source values.
|
||||
// It performs deep merging for complex types like Header, Row, Footer, and Stream.
|
||||
func mergeConfig(dst, src Config) Config {
|
||||
if src.MaxWidth != 0 {
|
||||
dst.MaxWidth = src.MaxWidth
|
||||
}
|
||||
|
||||
dst.Debug = src.Debug || dst.Debug
|
||||
dst.Behavior.AutoHide = src.Behavior.AutoHide
|
||||
dst.Behavior.TrimSpace = src.Behavior.TrimSpace
|
||||
dst.Behavior.TrimTab = src.Behavior.TrimTab
|
||||
dst.Behavior.Compact = src.Behavior.Compact
|
||||
dst.Behavior.Header = src.Behavior.Header
|
||||
dst.Behavior.Footer = src.Behavior.Footer
|
||||
dst.Behavior.Footer = src.Behavior.Footer
|
||||
|
||||
dst.Behavior.Structs.AutoHeader = src.Behavior.Structs.AutoHeader
|
||||
|
||||
// check lent of tags
|
||||
if len(src.Behavior.Structs.Tags) > 0 {
|
||||
dst.Behavior.Structs.Tags = src.Behavior.Structs.Tags
|
||||
}
|
||||
|
||||
if src.Widths.Global != 0 {
|
||||
dst.Widths.Global = src.Widths.Global
|
||||
}
|
||||
if len(src.Widths.PerColumn) > 0 {
|
||||
if dst.Widths.PerColumn == nil {
|
||||
dst.Widths.PerColumn = make(map[int]int)
|
||||
}
|
||||
for k, v := range src.Widths.PerColumn {
|
||||
if v != 0 {
|
||||
dst.Widths.PerColumn[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dst.Header = mergeCellConfig(dst.Header, src.Header)
|
||||
dst.Row = mergeCellConfig(dst.Row, src.Row)
|
||||
dst.Footer = mergeCellConfig(dst.Footer, src.Footer)
|
||||
dst.Stream = mergeStreamConfig(dst.Stream, src.Stream)
|
||||
|
||||
return dst
|
||||
}
|
||||
|
||||
// mergeStreamConfig merges a source StreamConfig into a destination StreamConfig, prioritizing non-default source values.
|
||||
func mergeStreamConfig(dst, src tw.StreamConfig) tw.StreamConfig {
|
||||
if src.Enable {
|
||||
dst.Enable = true
|
||||
}
|
||||
|
||||
dst.StrictColumns = src.StrictColumns
|
||||
return dst
|
||||
}
|
||||
|
||||
// padLine pads a line to the specified column count by appending empty strings as needed.
|
||||
func padLine(line []string, numCols int) []string {
|
||||
if len(line) >= numCols {
|
||||
return line
|
||||
}
|
||||
padded := make([]string, numCols)
|
||||
copy(padded, line)
|
||||
for i := len(line); i < numCols; i++ {
|
||||
padded[i] = tw.Empty
|
||||
}
|
||||
return padded
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package twcache
|
||||
|
||||
// Cache defines a generic interface for a key-value storage with type constraints on keys and values.
|
||||
// The keys must be of a type that supports comparison.
|
||||
// Add inserts a new key-value pair, potentially evicting an item if necessary.
|
||||
// Get retrieves a value associated with the given key, returning a boolean to indicate if the key was found.
|
||||
// Purge clears all items from the cache.
|
||||
type Cache[K comparable, V any] interface {
|
||||
Add(key K, value V) (evicted bool)
|
||||
Get(key K) (value V, ok bool)
|
||||
Purge()
|
||||
}
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
package twcache
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// EvictCallback is a function called when an entry is evicted.
|
||||
// This includes evictions during Purge or Resize operations.
|
||||
type EvictCallback[K comparable, V any] func(key K, value V)
|
||||
|
||||
// LRU is a thread-safe, generic LRU cache with a fixed size.
|
||||
// It has zero dependencies, high performance, and full features.
|
||||
type LRU[K comparable, V any] struct {
|
||||
size int
|
||||
items map[K]*entry[K, V]
|
||||
head *entry[K, V] // Most Recently Used
|
||||
tail *entry[K, V] // Least Recently Used
|
||||
onEvict EvictCallback[K, V]
|
||||
|
||||
mu sync.Mutex
|
||||
hits atomic.Int64
|
||||
misses atomic.Int64
|
||||
}
|
||||
|
||||
// entry represents a single item in the LRU linked list.
|
||||
// It holds the key, value, and pointers to prev/next entries.
|
||||
type entry[K comparable, V any] struct {
|
||||
key K
|
||||
value V
|
||||
prev *entry[K, V]
|
||||
next *entry[K, V]
|
||||
}
|
||||
|
||||
// NewLRU creates a new LRU cache with the given size.
|
||||
// Returns nil if size <= 0, acting as a disabled cache.
|
||||
// Caps size at 100,000 for reasonableness.
|
||||
func NewLRU[K comparable, V any](size int) *LRU[K, V] {
|
||||
return NewLRUEvict[K, V](size, nil)
|
||||
}
|
||||
|
||||
// NewLRUEvict creates a new LRU cache with an eviction callback.
|
||||
// The callback is optional and called on evictions.
|
||||
// Returns nil if size <= 0.
|
||||
func NewLRUEvict[K comparable, V any](size int, onEvict EvictCallback[K, V]) *LRU[K, V] {
|
||||
if size <= 0 {
|
||||
return nil // nil = disabled cache (fast path in hot code)
|
||||
}
|
||||
if size > 100_000 {
|
||||
size = 100_000 // reasonable upper bound
|
||||
}
|
||||
return &LRU[K, V]{
|
||||
size: size,
|
||||
items: make(map[K]*entry[K, V], size),
|
||||
onEvict: onEvict,
|
||||
}
|
||||
}
|
||||
|
||||
// GetOrCompute retrieves a value or computes it if missing.
|
||||
// Ensures no double computation under concurrency.
|
||||
// Ideal for expensive computations like twwidth.
|
||||
func (c *LRU[K, V]) GetOrCompute(key K, compute func() V) V {
|
||||
if c == nil || c.size <= 0 {
|
||||
return compute()
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
if e, ok := c.items[key]; ok {
|
||||
c.moveToFront(e)
|
||||
c.hits.Add(1)
|
||||
c.mu.Unlock()
|
||||
return e.value
|
||||
}
|
||||
|
||||
c.misses.Add(1)
|
||||
value := compute() // expensive work only on real miss
|
||||
|
||||
// Double-check: someone might have added it while computing
|
||||
if e, ok := c.items[key]; ok {
|
||||
e.value = value
|
||||
c.moveToFront(e)
|
||||
c.mu.Unlock()
|
||||
return value
|
||||
}
|
||||
|
||||
// Evict if needed
|
||||
if len(c.items) >= c.size {
|
||||
c.removeOldest()
|
||||
}
|
||||
|
||||
e := &entry[K, V]{key: key, value: value}
|
||||
c.addToFront(e)
|
||||
c.items[key] = e
|
||||
c.mu.Unlock()
|
||||
return value
|
||||
}
|
||||
|
||||
// Get retrieves a value by key if it exists.
|
||||
// Returns the value and true if found, else zero and false.
|
||||
// Updates the entry to most recently used.
|
||||
func (c *LRU[K, V]) Get(key K) (V, bool) {
|
||||
if c == nil || c.size <= 0 {
|
||||
var zero V
|
||||
return zero, false
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
e, ok := c.items[key]
|
||||
if !ok {
|
||||
c.misses.Add(1)
|
||||
var zero V
|
||||
return zero, false
|
||||
}
|
||||
c.hits.Add(1)
|
||||
c.moveToFront(e)
|
||||
return e.value, true
|
||||
}
|
||||
|
||||
// Add inserts or updates a key-value pair.
|
||||
// Evicts the oldest if cache is full.
|
||||
// Returns true if an eviction occurred.
|
||||
func (c *LRU[K, V]) Add(key K, value V) (evicted bool) {
|
||||
if c == nil || c.size <= 0 {
|
||||
return false
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if e, ok := c.items[key]; ok {
|
||||
e.value = value
|
||||
c.moveToFront(e)
|
||||
return false
|
||||
}
|
||||
|
||||
if len(c.items) >= c.size {
|
||||
c.removeOldest()
|
||||
evicted = true
|
||||
}
|
||||
|
||||
e := &entry[K, V]{key: key, value: value}
|
||||
c.addToFront(e)
|
||||
c.items[key] = e
|
||||
return evicted
|
||||
}
|
||||
|
||||
// Remove deletes a key from the cache.
|
||||
// Returns true if the key was found and removed.
|
||||
func (c *LRU[K, V]) Remove(key K) bool {
|
||||
if c == nil || c.size <= 0 {
|
||||
return false
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
e, ok := c.items[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
c.removeNode(e)
|
||||
delete(c.items, key)
|
||||
return true
|
||||
}
|
||||
|
||||
// Purge clears all entries from the cache.
|
||||
// Calls onEvict for each entry if set.
|
||||
// Resets hit/miss counters.
|
||||
func (c *LRU[K, V]) Purge() {
|
||||
if c == nil || c.size <= 0 {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
if c.onEvict != nil {
|
||||
for key, e := range c.items {
|
||||
c.onEvict(key, e.value)
|
||||
}
|
||||
}
|
||||
c.items = make(map[K]*entry[K, V], c.size)
|
||||
c.head = nil
|
||||
c.tail = nil
|
||||
c.hits.Store(0)
|
||||
c.misses.Store(0)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// Len returns the current number of items in the cache.
|
||||
func (c *LRU[K, V]) Len() int {
|
||||
if c == nil || c.size <= 0 {
|
||||
return 0
|
||||
}
|
||||
c.mu.Lock()
|
||||
n := len(c.items)
|
||||
c.mu.Unlock()
|
||||
return n
|
||||
}
|
||||
|
||||
// Cap returns the maximum capacity of the cache.
|
||||
func (c *LRU[K, V]) Cap() int {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
return c.size
|
||||
}
|
||||
|
||||
// HitRate returns the cache hit ratio (0.0 to 1.0).
|
||||
// Based on hits / (hits + misses).
|
||||
func (c *LRU[K, V]) HitRate() float64 {
|
||||
h := c.hits.Load()
|
||||
m := c.misses.Load()
|
||||
total := h + m
|
||||
if total == 0 {
|
||||
return 0.0
|
||||
}
|
||||
return float64(h) / float64(total)
|
||||
}
|
||||
|
||||
// RemoveOldest removes and returns the least recently used item.
|
||||
// Returns key, value, and true if an item was removed.
|
||||
// Calls onEvict if set.
|
||||
func (c *LRU[K, V]) RemoveOldest() (key K, value V, ok bool) {
|
||||
if c == nil || c.size <= 0 {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.tail == nil {
|
||||
return
|
||||
}
|
||||
|
||||
key = c.tail.key
|
||||
value = c.tail.value
|
||||
|
||||
c.removeOldest()
|
||||
return key, value, true
|
||||
}
|
||||
|
||||
// moveToFront moves an entry to the front (MRU position).
|
||||
func (c *LRU[K, V]) moveToFront(e *entry[K, V]) {
|
||||
if c.head == e {
|
||||
return
|
||||
}
|
||||
c.removeNode(e)
|
||||
c.addToFront(e)
|
||||
}
|
||||
|
||||
// addToFront adds an entry to the front of the list.
|
||||
func (c *LRU[K, V]) addToFront(e *entry[K, V]) {
|
||||
e.prev = nil
|
||||
e.next = c.head
|
||||
if c.head != nil {
|
||||
c.head.prev = e
|
||||
}
|
||||
c.head = e
|
||||
if c.tail == nil {
|
||||
c.tail = e
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// removeNode removes an entry from the linked list.
|
||||
func (c *LRU[K, V]) removeNode(e *entry[K, V]) {
|
||||
if e.prev != nil {
|
||||
e.prev.next = e.next
|
||||
} else {
|
||||
c.head = e.next
|
||||
}
|
||||
if e.next != nil {
|
||||
e.next.prev = e.prev
|
||||
} else {
|
||||
c.tail = e.prev
|
||||
}
|
||||
e.prev = nil
|
||||
e.next = nil
|
||||
}
|
||||
|
||||
// removeOldest removes the tail entry (LRU).
|
||||
// Calls onEvict if set and deletes from map.
|
||||
func (c *LRU[K, V]) removeOldest() {
|
||||
if c.tail == nil {
|
||||
return
|
||||
}
|
||||
e := c.tail
|
||||
if c.onEvict != nil {
|
||||
c.onEvict(e.key, e.value)
|
||||
}
|
||||
c.removeNode(e)
|
||||
delete(c.items, e.key)
|
||||
}
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
// Copyright 2014 Oleku Konko All rights reserved.
|
||||
// Use of this source code is governed by a MIT
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// This module is a Table Writer API for the Go Programming Language.
|
||||
// The protocols were written in pure Go and works on windows and unix systems
|
||||
|
||||
package twwarp
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/clipperhouse/uax29/v2/graphemes"
|
||||
"github.com/olekukonko/tablewriter/pkg/twwidth"
|
||||
)
|
||||
|
||||
const (
|
||||
nl = "\n"
|
||||
sp = " "
|
||||
)
|
||||
|
||||
const defaultPenalty = 1e5
|
||||
|
||||
func SplitWords(s string) []string {
|
||||
words := make([]string, 0, len(s)/5)
|
||||
var wordBegin int
|
||||
wordPending := false
|
||||
for i, c := range s {
|
||||
if unicode.IsSpace(c) {
|
||||
if wordPending {
|
||||
words = append(words, s[wordBegin:i])
|
||||
wordPending = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !wordPending {
|
||||
wordBegin = i
|
||||
wordPending = true
|
||||
}
|
||||
}
|
||||
if wordPending {
|
||||
words = append(words, s[wordBegin:])
|
||||
}
|
||||
return words
|
||||
}
|
||||
|
||||
// WrapString wraps s into a paragraph of lines of length lim, with minimal
|
||||
// raggedness.
|
||||
func WrapString(s string, lim int) ([]string, int) {
|
||||
if s == sp {
|
||||
return []string{sp}, lim
|
||||
}
|
||||
words := SplitWords(s)
|
||||
if len(words) == 0 {
|
||||
return []string{""}, lim
|
||||
}
|
||||
var lines []string
|
||||
max := 0
|
||||
for _, v := range words {
|
||||
max = twwidth.Width(v)
|
||||
if max > lim {
|
||||
lim = max
|
||||
}
|
||||
}
|
||||
for _, line := range WrapWords(words, 1, lim, defaultPenalty) {
|
||||
lines = append(lines, strings.Join(line, sp))
|
||||
}
|
||||
return lines, lim
|
||||
}
|
||||
|
||||
// WrapStringWithSpaces wraps a string into lines of a specified display width while preserving
|
||||
// leading and trailing spaces. It splits the input string into words, condenses internal multiple
|
||||
// spaces to a single space, and wraps the content to fit within the given width limit, measured
|
||||
// using Unicode-aware display width. The function is used in the logging library to format log
|
||||
// messages for consistent output. It returns the wrapped lines as a slice of strings and the
|
||||
// adjusted width limit, which may increase if a single word exceeds the input limit. Thread-safe
|
||||
// as it does not modify shared state.
|
||||
func WrapStringWithSpaces(s string, lim int) ([]string, int) {
|
||||
if len(s) == 0 {
|
||||
return []string{""}, lim
|
||||
}
|
||||
if strings.TrimSpace(s) == "" { // All spaces
|
||||
if twwidth.Width(s) <= lim {
|
||||
return []string{s}, twwidth.Width(s)
|
||||
}
|
||||
// For very long all-space strings, "wrap" by truncating to the limit.
|
||||
if lim > 0 {
|
||||
substring, _ := stringToDisplayWidth(s, lim)
|
||||
return []string{substring}, lim
|
||||
}
|
||||
return []string{""}, lim
|
||||
}
|
||||
|
||||
var leadingSpaces, trailingSpaces, coreContent string
|
||||
firstNonSpace := strings.IndexFunc(s, func(r rune) bool { return !unicode.IsSpace(r) })
|
||||
leadingSpaces = s[:firstNonSpace]
|
||||
lastNonSpace := strings.LastIndexFunc(s, func(r rune) bool { return !unicode.IsSpace(r) })
|
||||
trailingSpaces = s[lastNonSpace+1:]
|
||||
coreContent = s[firstNonSpace : lastNonSpace+1]
|
||||
|
||||
if coreContent == "" {
|
||||
return []string{leadingSpaces + trailingSpaces}, lim
|
||||
}
|
||||
|
||||
words := SplitWords(coreContent)
|
||||
if len(words) == 0 {
|
||||
return []string{leadingSpaces + trailingSpaces}, lim
|
||||
}
|
||||
|
||||
var lines []string
|
||||
currentLim := lim
|
||||
|
||||
maxCoreWordWidth := 0
|
||||
for _, v := range words {
|
||||
w := twwidth.Width(v)
|
||||
if w > maxCoreWordWidth {
|
||||
maxCoreWordWidth = w
|
||||
}
|
||||
}
|
||||
|
||||
if maxCoreWordWidth > currentLim {
|
||||
currentLim = maxCoreWordWidth
|
||||
}
|
||||
|
||||
wrappedWordLines := WrapWords(words, 1, currentLim, defaultPenalty)
|
||||
|
||||
for i, lineWords := range wrappedWordLines {
|
||||
joinedLine := strings.Join(lineWords, sp)
|
||||
finalLine := leadingSpaces + joinedLine
|
||||
if i == len(wrappedWordLines)-1 { // Last line
|
||||
finalLine += trailingSpaces
|
||||
}
|
||||
lines = append(lines, finalLine)
|
||||
}
|
||||
return lines, currentLim
|
||||
}
|
||||
|
||||
// stringToDisplayWidth returns a substring of s that has a display width
|
||||
// as close as possible to, but not exceeding, targetWidth.
|
||||
// It returns the substring and its actual display width.
|
||||
func stringToDisplayWidth(s string, targetWidth int) (substring string, actualWidth int) {
|
||||
if targetWidth <= 0 {
|
||||
return "", 0
|
||||
}
|
||||
|
||||
var currentWidth int
|
||||
var endIndex int // Tracks the byte index in the original string
|
||||
|
||||
g := graphemes.FromString(s)
|
||||
for g.Next() {
|
||||
grapheme := g.Value()
|
||||
graphemeWidth := twwidth.Width(grapheme)
|
||||
|
||||
if currentWidth+graphemeWidth > targetWidth {
|
||||
break
|
||||
}
|
||||
|
||||
currentWidth += graphemeWidth
|
||||
endIndex = g.End()
|
||||
}
|
||||
return s[:endIndex], currentWidth
|
||||
}
|
||||
|
||||
// WrapWords is the low-level line-breaking algorithm, useful if you need more
|
||||
// control over the details of the text wrapping process. For most uses,
|
||||
// WrapString will be sufficient and more convenient.
|
||||
//
|
||||
// WrapWords splits a list of words into lines with minimal "raggedness",
|
||||
// treating each rune as one unit, accounting for spc units between adjacent
|
||||
// words on each line, and attempting to limit lines to lim units. Raggedness
|
||||
// is the total error over all lines, where error is the square of the
|
||||
// difference of the length of the line and lim. Too-long lines (which only
|
||||
// happen when a single word is longer than lim units) have pen penalty units
|
||||
// added to the error.
|
||||
func WrapWords(words []string, spc, lim, pen int) [][]string {
|
||||
n := len(words)
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
lengths := make([]int, n)
|
||||
for i := 0; i < n; i++ {
|
||||
lengths[i] = twwidth.Width(words[i])
|
||||
}
|
||||
nbrk := make([]int, n)
|
||||
cost := make([]int, n)
|
||||
for i := range cost {
|
||||
cost[i] = math.MaxInt32
|
||||
}
|
||||
remainderLen := lengths[n-1] // Uses updated lengths
|
||||
for i := n - 1; i >= 0; i-- {
|
||||
if i < n-1 {
|
||||
remainderLen += spc + lengths[i]
|
||||
}
|
||||
if remainderLen <= lim {
|
||||
cost[i] = 0
|
||||
nbrk[i] = n
|
||||
continue
|
||||
}
|
||||
phraseLen := lengths[i]
|
||||
for j := i + 1; j < n; j++ {
|
||||
if j > i+1 {
|
||||
phraseLen += spc + lengths[j-1]
|
||||
}
|
||||
d := lim - phraseLen
|
||||
c := d*d + cost[j]
|
||||
if phraseLen > lim {
|
||||
c += pen // too-long lines get a worse penalty
|
||||
}
|
||||
if c < cost[i] {
|
||||
cost[i] = c
|
||||
nbrk[i] = j
|
||||
}
|
||||
}
|
||||
}
|
||||
var lines [][]string
|
||||
i := 0
|
||||
for i < n {
|
||||
lines = append(lines, words[i:nbrk[i]])
|
||||
i = nbrk[i]
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// getLines decomposes a multiline string into a slice of strings.
|
||||
func getLines(s string) []string {
|
||||
return strings.Split(s, nl)
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package twwidth
|
||||
|
||||
import "github.com/olekukonko/tablewriter/pkg/twcache"
|
||||
|
||||
// widthCache stores memoized results of Width calculations to improve performance.
|
||||
var widthCache *twcache.LRU[cacheKey, int]
|
||||
|
||||
type cacheKey struct {
|
||||
eastAsian bool
|
||||
str string
|
||||
}
|
||||
|
||||
// SetCacheCapacity changes the cache size dynamically
|
||||
// If capacity <= 0, disables caching entirely
|
||||
func SetCacheCapacity(capacity int) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if capacity <= 0 {
|
||||
widthCache = nil // nil = fully disabled
|
||||
return
|
||||
}
|
||||
|
||||
newCache := twcache.NewLRU[cacheKey, int](capacity)
|
||||
widthCache = newCache
|
||||
}
|
||||
+424
@@ -0,0 +1,424 @@
|
||||
/*
|
||||
Package twwidth provides intelligent East Asian width detection.
|
||||
|
||||
In 2025/2026, most modern terminal emulators (VSCode, Windows Terminal, iTerm2,
|
||||
Alacritty) and modern monospace fonts (Hack, Fira Code, Cascadia Code) treat
|
||||
box-drawing characters as Single Width, regardless of the underlying OS Locale.
|
||||
|
||||
Detection Logic (in order of priority):
|
||||
- RUNEWIDTH_EASTASIAN environment variable (explicit user override)
|
||||
- Force Legacy Mode (programmatic override for backward compatibility)
|
||||
- Modern environment detection (VSCode, Windows Terminal, etc. -> Narrow)
|
||||
- Locale-based detection (CJK locales in traditional terminals -> Wide)
|
||||
|
||||
This prioritization ensures that:
|
||||
- Users can always override behavior using RUNEWIDTH_EASTASIAN
|
||||
- Modern development environments work correctly by default
|
||||
- Traditional CJK terminals maintain compatibility via locale checks
|
||||
|
||||
Examples:
|
||||
|
||||
// Force narrow borders (for Hack font in zh_CN)
|
||||
RUNEWIDTH_EASTASIAN=0 go run .
|
||||
|
||||
// Force wide borders (for legacy CJK terminals)
|
||||
RUNEWIDTH_EASTASIAN=1 go run .
|
||||
*/
|
||||
package twwidth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Environment Variable Constants
|
||||
const (
|
||||
EnvLCAll = "LC_ALL"
|
||||
EnvLCCtype = "LC_CTYPE"
|
||||
EnvLang = "LANG"
|
||||
EnvRuneWidthEastAsian = "RUNEWIDTH_EASTASIAN"
|
||||
EnvTerm = "TERM"
|
||||
EnvTermProgram = "TERM_PROGRAM"
|
||||
EnvTermProgramWsl = "TERM_PROGRAM_WSL"
|
||||
EnvWTProfile = "WT_PROFILE_ID" // Windows Terminal
|
||||
EnvConEmuANSI = "ConEmuANSI" // ConEmu
|
||||
EnvAlacritty = "ALACRITTY_LOG" // Alacritty
|
||||
EnvVTEVersion = "VTE_VERSION" // GNOME/VTE
|
||||
)
|
||||
|
||||
const (
|
||||
overwriteOn = "override_on"
|
||||
overwriteOff = "override_off"
|
||||
|
||||
envModern = "modern_env"
|
||||
envCjk = "locale_cjk"
|
||||
envAscii = "default_ascii"
|
||||
)
|
||||
|
||||
// CJK Language Codes (Prefixes)
|
||||
// Covers ISO 639-1 (2-letter) and common full names used in some systems.
|
||||
var cjkPrefixes = []string{
|
||||
"zh", "ja", "ko", // Standard: Chinese, Japanese, Korean
|
||||
"chi", "zho", // ISO 639-2/B and T for Chinese
|
||||
"jpn", "kor", // ISO 639-2 for Japanese, Korean
|
||||
"chinese", "japanese", "korean", // Full names (rare but possible in some legacy systems)
|
||||
}
|
||||
|
||||
// CJK Region Codes
|
||||
// Checks for specific regions that imply CJK font usage (e.g., en_HK).
|
||||
var cjkRegions = map[string]bool{
|
||||
"cn": true, // China
|
||||
"tw": true, // Taiwan
|
||||
"hk": true, // Hong Kong
|
||||
"mo": true, // Macau
|
||||
"jp": true, // Japan
|
||||
"kr": true, // South Korea
|
||||
"kp": true, // North Korea
|
||||
"sg": true, // Singapore (Often uses CJK fonts)
|
||||
}
|
||||
|
||||
// Modern environments that should use narrow borders (1-width box chars)
|
||||
var modernEnvironments = map[string]bool{
|
||||
// Terminal programs
|
||||
"vscode": true, "visual studio code": true,
|
||||
"iterm.app": true, "iterm2": true,
|
||||
"windows terminal": true, "windowsterminal": true,
|
||||
"alacritty": true, "kitty": true,
|
||||
"hyper": true, "tabby": true, "terminus": true, "fluentterminal": true,
|
||||
"warp": true, "ghostty": true, "rio": true,
|
||||
"jetbrains-jediterm": true,
|
||||
|
||||
// Terminal types (TERM signatures)
|
||||
"xterm-kitty": true, "xterm-ghostty": true, "wezterm": true,
|
||||
}
|
||||
|
||||
var (
|
||||
eastAsianOnce sync.Once
|
||||
eastAsianVal bool
|
||||
|
||||
// Legacy override control
|
||||
// Renamed to cfgMu to avoid conflict with width.go's mu
|
||||
cfgMu sync.RWMutex
|
||||
forceLegacyEastAsian = false
|
||||
)
|
||||
|
||||
type Enviroment struct {
|
||||
GOOS string `json:"goos"`
|
||||
LC_ALL string `json:"lc_all"`
|
||||
LC_CTYPE string `json:"lc_ctype"`
|
||||
LANG string `json:"lang"`
|
||||
RUNEWIDTH_EASTASIAN string `json:"runewidth_eastasian"`
|
||||
TERM string `json:"term"`
|
||||
TERM_PROGRAM string `json:"term_program"`
|
||||
}
|
||||
|
||||
// State captures the calculated internal state.
|
||||
type State struct {
|
||||
NormalizedLocale string `json:"normalized_locale"`
|
||||
IsCJKLocale bool `json:"is_cjk_locale"`
|
||||
IsModernEnv bool `json:"is_modern_env"`
|
||||
LegacyOverrideMode bool `json:"legacy_override_mode"`
|
||||
}
|
||||
|
||||
// Detection aggregates all debug information regarding East Asian width detection.
|
||||
type Detection struct {
|
||||
AutoUseEastAsian bool `json:"auto_use_east_asian"`
|
||||
DetectionMode string `json:"detection_mode"`
|
||||
Raw Enviroment `json:"raw"`
|
||||
Derived State `json:"derived"`
|
||||
}
|
||||
|
||||
// EastAsianForceLegacy forces the detection logic to ignore modern environment checks.
|
||||
// It relies solely on Locale detection. This is useful for applications that need
|
||||
// strict backward compatibility.
|
||||
//
|
||||
// Note: This does NOT override RUNEWIDTH_EASTASIAN. User environment variables take precedence.
|
||||
// This should be called before the first table render.
|
||||
func EastAsianForceLegacy(force bool) {
|
||||
cfgMu.Lock()
|
||||
defer cfgMu.Unlock()
|
||||
forceLegacyEastAsian = force
|
||||
}
|
||||
|
||||
// EastAsianDetect checks the environment variables to determine if
|
||||
// East Asian width calculations should be enabled.
|
||||
func EastAsianDetect() bool {
|
||||
eastAsianOnce.Do(func() {
|
||||
eastAsianVal = detectEastAsian()
|
||||
})
|
||||
return eastAsianVal
|
||||
}
|
||||
|
||||
// EastAsianConservative is a stricter version that only defaults to Narrow
|
||||
// if the terminal is definitely known to be modern (e.g. VSCode, iTerm2).
|
||||
// It avoids heuristics like checking "xterm" in the TERM variable.
|
||||
func EastAsianConservative() bool {
|
||||
// Check overrides first
|
||||
if val, found := checkOverrides(); found {
|
||||
return val
|
||||
}
|
||||
|
||||
// Stricter modern environment detection
|
||||
if isConservativeModernEnvironment() {
|
||||
return false
|
||||
}
|
||||
|
||||
// Fall back to locale
|
||||
return checkLocale()
|
||||
}
|
||||
|
||||
// EastAsianMode returns the decision path used for the current environment.
|
||||
// Useful for debugging why a specific width was chosen.
|
||||
func EastAsianMode() string {
|
||||
// Check override
|
||||
if val, found := checkOverrides(); found {
|
||||
if val {
|
||||
return overwriteOn
|
||||
}
|
||||
return overwriteOff
|
||||
}
|
||||
|
||||
cfgMu.RLock()
|
||||
legacy := forceLegacyEastAsian
|
||||
cfgMu.RUnlock()
|
||||
|
||||
if legacy {
|
||||
if checkLocale() {
|
||||
return envCjk
|
||||
}
|
||||
return envAscii
|
||||
}
|
||||
|
||||
if isModernEnvironment() {
|
||||
return envModern
|
||||
}
|
||||
|
||||
if checkLocale() {
|
||||
return envCjk
|
||||
}
|
||||
|
||||
return envAscii
|
||||
}
|
||||
|
||||
// Debugging returns detailed information about the detection decision.
|
||||
// Useful for users to include in Github issues.
|
||||
func Debugging() Detection {
|
||||
locale := getNormalizedLocale()
|
||||
|
||||
cfgMu.RLock()
|
||||
legacy := forceLegacyEastAsian
|
||||
cfgMu.RUnlock()
|
||||
|
||||
return Detection{
|
||||
AutoUseEastAsian: EastAsianDetect(),
|
||||
DetectionMode: EastAsianMode(),
|
||||
Raw: Enviroment{
|
||||
GOOS: runtime.GOOS,
|
||||
LC_ALL: os.Getenv(EnvLCAll),
|
||||
LC_CTYPE: os.Getenv(EnvLCCtype),
|
||||
LANG: os.Getenv(EnvLang),
|
||||
RUNEWIDTH_EASTASIAN: os.Getenv(EnvRuneWidthEastAsian),
|
||||
TERM: os.Getenv(EnvTerm),
|
||||
TERM_PROGRAM: os.Getenv(EnvTermProgram),
|
||||
},
|
||||
Derived: State{
|
||||
NormalizedLocale: locale,
|
||||
IsCJKLocale: isCJKLocale(locale),
|
||||
IsModernEnv: isModernEnvironment(),
|
||||
LegacyOverrideMode: legacy,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// detectEastAsian evaluates the environment and locale settings to determine if East Asian width rules should apply.
|
||||
func detectEastAsian() bool {
|
||||
// User Override check (Highest Priority)
|
||||
if val, found := checkOverrides(); found {
|
||||
return val
|
||||
}
|
||||
|
||||
// Force Legacy Mode check
|
||||
cfgMu.RLock()
|
||||
isLegacy := forceLegacyEastAsian
|
||||
cfgMu.RUnlock()
|
||||
|
||||
if isLegacy {
|
||||
// Legacy mode ignores modern environment checks,
|
||||
// relying solely on locale.
|
||||
return checkLocale()
|
||||
}
|
||||
|
||||
// Modern Environment Detection
|
||||
// If modern, we assume Single Width (return false)
|
||||
if isModernEnvironment() {
|
||||
return false
|
||||
}
|
||||
|
||||
// 4. Locale Fallback
|
||||
return checkLocale()
|
||||
}
|
||||
|
||||
// checkOverrides looks for RUNEWIDTH_EASTASIAN
|
||||
func checkOverrides() (bool, bool) {
|
||||
if rw := os.Getenv(EnvRuneWidthEastAsian); rw != "" {
|
||||
rw = strings.ToLower(rw)
|
||||
if rw == "0" || rw == "off" || rw == "false" || rw == "no" {
|
||||
return false, true
|
||||
}
|
||||
if rw == "1" || rw == "on" || rw == "true" || rw == "yes" {
|
||||
return true, true
|
||||
}
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
// checkLocale performs the string analysis on LANG/LC_ALL
|
||||
func checkLocale() bool {
|
||||
locale := getNormalizedLocale()
|
||||
if locale == "" {
|
||||
return false
|
||||
}
|
||||
return isCJKLocale(locale)
|
||||
}
|
||||
|
||||
// isModernEnvironment performs comprehensive checks for modern terminal capabilities.
|
||||
func isModernEnvironment() bool {
|
||||
// Check TERM_PROGRAM (Most reliable)
|
||||
if termProg := os.Getenv(EnvTermProgram); termProg != "" {
|
||||
termProgLower := strings.ToLower(termProg)
|
||||
if modernEnvironments[termProgLower] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check WSL specific variable
|
||||
if os.Getenv(EnvTermProgramWsl) != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Windows Specifics
|
||||
if runtime.GOOS == "windows" {
|
||||
// Windows Terminal
|
||||
if os.Getenv(EnvWTProfile) != "" {
|
||||
return true
|
||||
}
|
||||
// ConEmu/Cmder
|
||||
if os.Getenv(EnvConEmuANSI) == "ON" {
|
||||
return true
|
||||
}
|
||||
// Modern Windows console (Windows 10+) check via TERM
|
||||
if term := os.Getenv(EnvTerm); term != "" {
|
||||
termLower := strings.ToLower(term)
|
||||
if strings.Contains(termLower, "xterm") ||
|
||||
strings.Contains(termLower, "vt") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// VTE-based terminals (GNOME Terminal, Tilix, etc.)
|
||||
if os.Getenv(EnvVTEVersion) != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for Alacritty specifically
|
||||
if os.Getenv(EnvAlacritty) != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check TERM for modern terminal signatures
|
||||
if term := os.Getenv(EnvTerm); term != "" {
|
||||
termLower := strings.ToLower(term)
|
||||
// Specific modern terminals often put their name in TERM
|
||||
if modernEnvironments[termLower] {
|
||||
return true
|
||||
}
|
||||
// Heuristics for standard modern-capable descriptors
|
||||
if strings.Contains(termLower, "xterm") && !strings.Contains(termLower, "xterm-mono") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(termLower, "screen") ||
|
||||
strings.Contains(termLower, "tmux") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// isConservativeModernEnvironment performs strict checks only for known modern terminals.
|
||||
func isConservativeModernEnvironment() bool {
|
||||
termProg := strings.ToLower(os.Getenv(EnvTermProgram))
|
||||
|
||||
// Allow-list of definitely modern terminals
|
||||
switch termProg {
|
||||
case "vscode", "visual studio code":
|
||||
return true
|
||||
case "iterm.app", "iterm2":
|
||||
return true
|
||||
case "windows terminal", "windowsterminal":
|
||||
return true
|
||||
case "alacritty", "wezterm", "kitty", "ghostty":
|
||||
return true
|
||||
case "warp", "tabby", "hyper":
|
||||
return true
|
||||
}
|
||||
|
||||
// Windows Terminal via specific Env
|
||||
if os.Getenv(EnvWTProfile) != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// isCJKLocale determines if a given locale string corresponds to a CJK (Chinese, Japanese, Korean) language or region.
|
||||
func isCJKLocale(locale string) bool {
|
||||
// Check Language Prefix
|
||||
for _, prefix := range cjkPrefixes {
|
||||
if strings.HasPrefix(locale, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check Regions
|
||||
parts := strings.Split(locale, "_")
|
||||
if len(parts) > 1 {
|
||||
for _, part := range parts[1:] {
|
||||
if cjkRegions[part] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// getNormalizedLocale returns the normalized locale by inspecting environment variables LC_ALL, LC_CTYPE, and LANG.
|
||||
func getNormalizedLocale() string {
|
||||
var locale string
|
||||
if loc := os.Getenv(EnvLCAll); loc != "" {
|
||||
locale = loc
|
||||
} else if loc := os.Getenv(EnvLCCtype); loc != "" {
|
||||
locale = loc
|
||||
} else if loc := os.Getenv(EnvLang); loc != "" {
|
||||
locale = loc
|
||||
}
|
||||
|
||||
// Fast fail for empty or standard C/POSIX locales
|
||||
if locale == "" || locale == "C" || locale == "POSIX" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Strip encoding and modifiers
|
||||
if idx := strings.IndexByte(locale, '.'); idx != -1 {
|
||||
locale = locale[:idx]
|
||||
}
|
||||
if idx := strings.IndexByte(locale, '@'); idx != -1 {
|
||||
locale = locale[:idx]
|
||||
}
|
||||
|
||||
return strings.ToLower(locale)
|
||||
}
|
||||
+288
@@ -0,0 +1,288 @@
|
||||
package twwidth
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Tab rune
|
||||
|
||||
const (
|
||||
TabWidthDefault = 8
|
||||
TabString Tab = '\t'
|
||||
)
|
||||
|
||||
// IsTab returns true if t equals the default tab.
|
||||
func (t Tab) IsTab() bool {
|
||||
return t == TabString
|
||||
}
|
||||
|
||||
func (t Tab) Byte() byte {
|
||||
return byte(t)
|
||||
}
|
||||
|
||||
func (t Tab) Rune() rune {
|
||||
return rune(t)
|
||||
}
|
||||
|
||||
func (t Tab) String() string {
|
||||
return string(t)
|
||||
}
|
||||
|
||||
// IsTab returns true if r is a tab rune.
|
||||
func IsTab(r rune) bool {
|
||||
return r == TabString.Rune()
|
||||
}
|
||||
|
||||
type Tabinal struct {
|
||||
once sync.Once
|
||||
width int
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func (t *Tabinal) String() string {
|
||||
return TabString.String()
|
||||
}
|
||||
|
||||
// Size returns the current tab width, default if unset.
|
||||
func (t *Tabinal) Size() int {
|
||||
t.once.Do(t.init)
|
||||
|
||||
t.mu.RLock()
|
||||
w := t.width
|
||||
t.mu.RUnlock()
|
||||
|
||||
if w <= 0 {
|
||||
return TabWidthDefault
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// SetWidth sets the tab width if valid (1–32).
|
||||
func (t *Tabinal) SetWidth(w int) {
|
||||
if w <= 0 || w > 32 {
|
||||
return
|
||||
}
|
||||
t.mu.Lock()
|
||||
t.width = w
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
func (t *Tabinal) init() {
|
||||
w := t.detect()
|
||||
t.mu.Lock()
|
||||
t.width = w
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
// detect determines tab width using env, editorconfig, project, or term.
|
||||
func (t *Tabinal) detect() int {
|
||||
if w := envInt("TABWIDTH"); w > 0 {
|
||||
return clamp(w)
|
||||
}
|
||||
if w := envInt("TS"); w > 0 {
|
||||
return clamp(w)
|
||||
}
|
||||
if w := envInt("VIM_TABSTOP"); w > 0 {
|
||||
return clamp(w)
|
||||
}
|
||||
if w := editorConfigTabWidth(); w > 0 {
|
||||
return w
|
||||
}
|
||||
if w := projectHeuristic(); w > 0 {
|
||||
return w
|
||||
}
|
||||
if w := termHeuristic(); w > 0 {
|
||||
return w
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func editorConfigTabWidth() int {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
for dir != "" && dir != "/" && dir != "." {
|
||||
path := filepath.Join(dir, ".editorconfig")
|
||||
if w := parseEditorConfig(path); w > 0 {
|
||||
return clamp(w)
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// parseEditorConfig reads tab_width or indent_size from a file.
|
||||
func parseEditorConfig(path string) int {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
inMatch := false
|
||||
globalWidth := 0
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||||
pattern := line[1 : len(line)-1]
|
||||
inMatch = pattern == "*"
|
||||
|
||||
knownExts := []string{".go", ".py", ".js", ".ts", ".java", ".rs"}
|
||||
for _, ext := range knownExts {
|
||||
if strings.Contains(pattern, ext) {
|
||||
inMatch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if !inMatch && globalWidth == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
val := strings.TrimSpace(parts[1])
|
||||
|
||||
switch key {
|
||||
case "tab_width":
|
||||
if w, err := strconv.Atoi(val); err == nil && w > 0 {
|
||||
if inMatch {
|
||||
return clamp(w)
|
||||
}
|
||||
if globalWidth == 0 {
|
||||
globalWidth = w
|
||||
}
|
||||
}
|
||||
case "indent_size":
|
||||
if val == "tab" {
|
||||
continue
|
||||
}
|
||||
if w, err := strconv.Atoi(val); err == nil && w > 0 {
|
||||
if inMatch {
|
||||
return clamp(w)
|
||||
}
|
||||
if globalWidth == 0 {
|
||||
globalWidth = w
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return globalWidth
|
||||
}
|
||||
|
||||
// projectHeuristic returns 4 for known project types.
|
||||
func projectHeuristic() int {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
indicators := []string{
|
||||
"go.mod", "go.sum",
|
||||
"package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml",
|
||||
"setup.py", "pyproject.toml", "requirements.txt", "Pipfile",
|
||||
"pom.xml", "build.gradle", "build.gradle.kts",
|
||||
"Cargo.toml",
|
||||
"composer.json",
|
||||
}
|
||||
|
||||
for _, indicator := range indicators {
|
||||
if _, err := os.Stat(filepath.Join(dir, indicator)); err == nil {
|
||||
return 4
|
||||
}
|
||||
}
|
||||
|
||||
patterns := []string{"*.go", "*.py", "*.js", "*.ts", "*.java", "*.rs"}
|
||||
for _, pattern := range patterns {
|
||||
if matches, _ := filepath.Glob(filepath.Join(dir, pattern)); len(matches) > 0 {
|
||||
return 4
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// termHeuristic returns a default width based on the TERM variable.
|
||||
func termHeuristic() int {
|
||||
termEnv := strings.ToLower(os.Getenv("TERM"))
|
||||
if termEnv == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
if strings.Contains(termEnv, "vt52") {
|
||||
return 2
|
||||
}
|
||||
|
||||
if strings.Contains(termEnv, "xterm") ||
|
||||
strings.Contains(termEnv, "screen") ||
|
||||
strings.Contains(termEnv, "tmux") ||
|
||||
strings.Contains(termEnv, "linux") ||
|
||||
strings.Contains(termEnv, "ansi") ||
|
||||
strings.Contains(termEnv, "rxvt") {
|
||||
return TabWidthDefault
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func clamp(w int) int {
|
||||
if w <= 0 {
|
||||
return 0
|
||||
}
|
||||
if w > 32 {
|
||||
return 32
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
var (
|
||||
globalTab *Tabinal
|
||||
globalTabOnce sync.Once
|
||||
)
|
||||
|
||||
// TabInstance returns the singleton Tabinal.
|
||||
func TabInstance() *Tabinal {
|
||||
globalTabOnce.Do(func() {
|
||||
globalTab = &Tabinal{}
|
||||
})
|
||||
return globalTab
|
||||
}
|
||||
|
||||
// TabWidth returns the detected global tab width.
|
||||
func TabWidth() int {
|
||||
return TabInstance().Size()
|
||||
}
|
||||
|
||||
// SetTabWidth sets the global tab width.
|
||||
func SetTabWidth(w int) {
|
||||
TabInstance().SetWidth(w)
|
||||
}
|
||||
|
||||
func envInt(k string) int {
|
||||
v := os.Getenv(k)
|
||||
w, _ := strconv.Atoi(v)
|
||||
return w
|
||||
}
|
||||
+419
@@ -0,0 +1,419 @@
|
||||
package twwidth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/clipperhouse/displaywidth"
|
||||
"github.com/mattn/go-runewidth"
|
||||
"github.com/olekukonko/tablewriter/pkg/twcache"
|
||||
)
|
||||
|
||||
const (
|
||||
cacheCapacity = 8192
|
||||
|
||||
cachePrefix = "0:"
|
||||
cacheEastAsianPrefix = "1:"
|
||||
)
|
||||
|
||||
// Options allows for configuring width calculation on a per-call basis.
|
||||
type Options struct {
|
||||
EastAsianWidth bool
|
||||
|
||||
// Explicitly force box drawing chars to be narrow
|
||||
// regardless of EastAsianWidth setting.
|
||||
ForceNarrowBorders bool
|
||||
}
|
||||
|
||||
// globalOptions holds the global displaywidth configuration, including East Asian width settings.
|
||||
var globalOptions Options
|
||||
|
||||
// mu protects access to globalOptions for thread safety.
|
||||
var mu sync.Mutex
|
||||
|
||||
// ansi is a compiled regular expression for stripping ANSI escape codes from strings.
|
||||
var ansi = Filter()
|
||||
|
||||
func init() {
|
||||
isEastAsian := EastAsianDetect()
|
||||
|
||||
cond := runewidth.NewCondition()
|
||||
cond.EastAsianWidth = isEastAsian
|
||||
|
||||
globalOptions = Options{
|
||||
EastAsianWidth: isEastAsian,
|
||||
|
||||
// Auto-enable ForceNarrowBorders for edge cases.
|
||||
// If EastAsianWidth is ON (e.g. forced via Env Var), but we detect
|
||||
// a modern environment, we might technically want to narrow borders
|
||||
// while keeping text wide.
|
||||
ForceNarrowBorders: isEastAsian && isModernEnvironment(),
|
||||
}
|
||||
|
||||
widthCache = twcache.NewLRU[cacheKey, int](cacheCapacity)
|
||||
}
|
||||
|
||||
// Display calculates the visual width of a string using a specific runewidth.Condition.
|
||||
// Deprecated: use WidthWithOptions with the new twwidth.Options struct instead.
|
||||
// This function is kept for backward compatibility.
|
||||
func Display(cond *runewidth.Condition, str string) int {
|
||||
opts := Options{EastAsianWidth: cond.EastAsianWidth}
|
||||
return WidthWithOptions(str, opts)
|
||||
}
|
||||
|
||||
// Filter compiles and returns a regular expression for matching ANSI escape sequences,
|
||||
// including CSI (Control Sequence Introducer) and OSC (Operating System Command) sequences.
|
||||
// The returned regex can be used to strip ANSI codes from strings.
|
||||
func Filter() *regexp.Regexp {
|
||||
regESC := "\x1b" // ASCII escape character
|
||||
regBEL := "\x07" // ASCII bell character
|
||||
|
||||
// ANSI string terminator: either ESC+\ or BEL
|
||||
regST := "(" + regexp.QuoteMeta(regESC+"\\") + "|" + regexp.QuoteMeta(regBEL) + ")"
|
||||
// Control Sequence Introducer (CSI): ESC[ followed by parameters and a final byte
|
||||
regCSI := regexp.QuoteMeta(regESC+"[") + "[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]"
|
||||
// Operating System Command (OSC): ESC] followed by arbitrary content until a terminator
|
||||
regOSC := regexp.QuoteMeta(regESC+"]") + ".*?" + regST
|
||||
|
||||
// Combine CSI and OSC patterns into a single regex
|
||||
return regexp.MustCompile("(" + regCSI + "|" + regOSC + ")")
|
||||
}
|
||||
|
||||
// GetCacheStats returns current cache statistics
|
||||
func GetCacheStats() (size, capacity int, hitRate float64) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if widthCache == nil {
|
||||
return 0, 0, 0
|
||||
}
|
||||
return widthCache.Len(), widthCache.Cap(), widthCache.HitRate()
|
||||
}
|
||||
|
||||
// IsEastAsian returns the current East Asian width setting.
|
||||
// This function is thread-safe.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// if twdw.IsEastAsian() {
|
||||
// // Handle East Asian width characters
|
||||
// }
|
||||
func IsEastAsian() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return globalOptions.EastAsianWidth
|
||||
}
|
||||
|
||||
// SetCondition sets the global East Asian width setting based on a runewidth.Condition.
|
||||
// Deprecated: use SetOptions with the new twwidth.Options struct instead.
|
||||
// This function is kept for backward compatibility.
|
||||
func SetCondition(cond *runewidth.Condition) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
newEastAsianWidth := cond.EastAsianWidth
|
||||
if globalOptions.EastAsianWidth != newEastAsianWidth {
|
||||
globalOptions.EastAsianWidth = newEastAsianWidth
|
||||
widthCache.Purge()
|
||||
}
|
||||
}
|
||||
|
||||
// SetEastAsian enables or disables East Asian width handling globally.
|
||||
// This function is thread-safe.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// twdw.SetEastAsian(true) // Enable East Asian width handling
|
||||
func SetEastAsian(enable bool) {
|
||||
SetOptions(Options{EastAsianWidth: enable})
|
||||
}
|
||||
|
||||
// SetForceNarrow to preserve the new flag, or create a new setter
|
||||
func SetForceNarrow(enable bool) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
globalOptions.ForceNarrowBorders = enable
|
||||
widthCache.Purge() // Clear cache because widths might change
|
||||
}
|
||||
|
||||
// SetOptions sets the global options for width calculation.
|
||||
// This function is thread-safe.
|
||||
func SetOptions(opts Options) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if globalOptions.EastAsianWidth != opts.EastAsianWidth || globalOptions.ForceNarrowBorders != opts.ForceNarrowBorders {
|
||||
globalOptions = opts
|
||||
widthCache.Purge()
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate shortens a string to fit within a specified visual width, optionally
|
||||
// appending a suffix (e.g., "..."). It preserves ANSI escape sequences and adds
|
||||
// a reset sequence (\x1b[0m) if needed to prevent formatting bleed. The function
|
||||
// respects the global East Asian width setting and is thread-safe.
|
||||
//
|
||||
// If maxWidth is negative, an empty string is returned. If maxWidth is zero and
|
||||
// a suffix is provided, the suffix is returned. If the string's visual width is
|
||||
// less than or equal to maxWidth, the string (and suffix, if provided and fits)
|
||||
// is returned unchanged.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// s := twdw.Truncate("Hello\x1b[31mWorld", 5, "...") // Returns "Hello..."
|
||||
// s = twdw.Truncate("Hello", 10) // Returns "Hello"
|
||||
func Truncate(s string, maxWidth int, suffix ...string) string {
|
||||
if maxWidth < 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
suffixStr := strings.Join(suffix, "")
|
||||
sDisplayWidth := Width(s) // Uses global cached Width
|
||||
suffixDisplayWidth := Width(suffixStr) // Uses global cached Width
|
||||
|
||||
// Case 1: Original string is visually empty.
|
||||
if sDisplayWidth == 0 {
|
||||
// If suffix is provided and fits within maxWidth (or if maxWidth is generous)
|
||||
if len(suffixStr) > 0 && suffixDisplayWidth <= maxWidth {
|
||||
return suffixStr
|
||||
}
|
||||
// If s has ANSI codes (len(s)>0) but maxWidth is 0, can't display them.
|
||||
if maxWidth == 0 && len(s) > 0 {
|
||||
return ""
|
||||
}
|
||||
return s // Returns "" or original ANSI codes
|
||||
}
|
||||
|
||||
// Case 2: maxWidth is 0, but string has content. Cannot display anything.
|
||||
if maxWidth == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Case 3: String fits completely or fits with suffix.
|
||||
// Here, maxWidth is the total budget for the line.
|
||||
if sDisplayWidth <= maxWidth {
|
||||
// If the string contains ANSI, we must ensure it ends with a reset
|
||||
// to prevent bleeding, even if we don't truncate.
|
||||
safeS := s
|
||||
if strings.Contains(s, "\x1b") && !strings.HasSuffix(s, "\x1b[0m") {
|
||||
safeS += "\x1b[0m"
|
||||
}
|
||||
|
||||
if len(suffixStr) == 0 { // No suffix.
|
||||
return safeS
|
||||
}
|
||||
// Suffix is provided. Check if s + suffix fits.
|
||||
if sDisplayWidth+suffixDisplayWidth <= maxWidth {
|
||||
return safeS + suffixStr
|
||||
}
|
||||
// s fits, but s + suffix is too long. Return s (with reset if needed).
|
||||
return safeS
|
||||
}
|
||||
|
||||
// Case 4: String needs truncation (sDisplayWidth > maxWidth).
|
||||
// maxWidth is the total budget for the final string (content + suffix).
|
||||
mu.Lock()
|
||||
currentOpts := globalOptions
|
||||
mu.Unlock()
|
||||
|
||||
// Special case for EastAsianDetect true: if only suffix fits, return suffix.
|
||||
// This was derived from previous test behavior.
|
||||
if len(suffixStr) > 0 && currentOpts.EastAsianWidth {
|
||||
provisionalContentWidth := maxWidth - suffixDisplayWidth
|
||||
if provisionalContentWidth == 0 { // Exactly enough space for suffix only
|
||||
return suffixStr
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate the budget for the content part, reserving space for the suffix.
|
||||
targetContentForIteration := maxWidth
|
||||
if len(suffixStr) > 0 {
|
||||
targetContentForIteration -= suffixDisplayWidth
|
||||
}
|
||||
|
||||
// If content budget is negative, means not even suffix fits (or no suffix and no space).
|
||||
// However, if only suffix fits, it should be handled.
|
||||
if targetContentForIteration < 0 {
|
||||
// Can we still fit just the suffix?
|
||||
if len(suffixStr) > 0 && suffixDisplayWidth <= maxWidth {
|
||||
if strings.Contains(s, "\x1b[") {
|
||||
return "\x1b[0m" + suffixStr
|
||||
}
|
||||
return suffixStr
|
||||
}
|
||||
return "" // Cannot fit anything.
|
||||
}
|
||||
|
||||
var contentBuf bytes.Buffer
|
||||
var currentContentDisplayWidth int
|
||||
var ansiSeqBuf bytes.Buffer
|
||||
inAnsiSequence := false
|
||||
ansiWrittenToContent := false
|
||||
|
||||
for _, r := range s {
|
||||
if r == '\x1b' {
|
||||
inAnsiSequence = true
|
||||
ansiSeqBuf.Reset()
|
||||
ansiSeqBuf.WriteRune(r)
|
||||
} else if inAnsiSequence {
|
||||
ansiSeqBuf.WriteRune(r)
|
||||
seqBytes := ansiSeqBuf.Bytes()
|
||||
seqLen := len(seqBytes)
|
||||
terminated := false
|
||||
if seqLen >= 2 {
|
||||
introducer := seqBytes[1]
|
||||
switch introducer {
|
||||
case '[':
|
||||
if seqLen >= 3 && r >= 0x40 && r <= 0x7E {
|
||||
terminated = true
|
||||
}
|
||||
case ']':
|
||||
if r == '\x07' {
|
||||
terminated = true
|
||||
} else if seqLen > 1 && seqBytes[seqLen-2] == '\x1b' && r == '\\' { // Check for ST: \x1b\
|
||||
terminated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if terminated {
|
||||
inAnsiSequence = false
|
||||
contentBuf.Write(ansiSeqBuf.Bytes())
|
||||
ansiWrittenToContent = true
|
||||
ansiSeqBuf.Reset()
|
||||
}
|
||||
} else { // Normal character
|
||||
runeDisplayWidth := calculateRunewidth(r, currentOpts)
|
||||
if targetContentForIteration == 0 { // No budget for content at all
|
||||
break
|
||||
}
|
||||
if currentContentDisplayWidth+runeDisplayWidth > targetContentForIteration {
|
||||
break
|
||||
}
|
||||
contentBuf.WriteRune(r)
|
||||
currentContentDisplayWidth += runeDisplayWidth
|
||||
}
|
||||
}
|
||||
|
||||
result := contentBuf.String()
|
||||
|
||||
// Determine if we need to append a reset sequence to prevent color bleeding.
|
||||
// This is needed if we wrote any ANSI codes or if the input had active codes
|
||||
// that we might have cut off or left open.
|
||||
needsReset := false
|
||||
if (ansiWrittenToContent || (inAnsiSequence && strings.Contains(s, "\x1b["))) && (currentContentDisplayWidth > 0 || ansiWrittenToContent) {
|
||||
if !strings.HasSuffix(result, "\x1b[0m") {
|
||||
needsReset = true
|
||||
}
|
||||
} else if currentContentDisplayWidth > 0 && strings.Contains(result, "\x1b[") && !strings.HasSuffix(result, "\x1b[0m") && strings.Contains(s, "\x1b[") {
|
||||
needsReset = true
|
||||
}
|
||||
|
||||
if needsReset {
|
||||
result += "\x1b[0m"
|
||||
}
|
||||
|
||||
// Suffix is added if provided.
|
||||
if len(suffixStr) > 0 {
|
||||
result += suffixStr
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Width calculates the visual width of a string using the global cache for performance.
|
||||
// It excludes ANSI escape sequences and accounts for the global East Asian width setting.
|
||||
// This function is thread-safe.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// width := twdw.Width("Hello\x1b[31mWorld") // Returns 10
|
||||
func Width(str string) int {
|
||||
// Fast path ASCII (Optimization)
|
||||
if len(str) == 1 && str[0] < 0x80 {
|
||||
// Treat tab as special case even in fast path
|
||||
if IsTab(rune(str[0])) {
|
||||
return TabWidth()
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
currentOpts := globalOptions
|
||||
mu.Unlock()
|
||||
|
||||
key := cacheKey{
|
||||
eastAsian: currentOpts.EastAsianWidth,
|
||||
str: str,
|
||||
}
|
||||
|
||||
// Check Cache (Optimization)
|
||||
if w, found := widthCache.Get(key); found {
|
||||
return w
|
||||
}
|
||||
|
||||
//stripped := ansi.ReplaceAllLiteralString(str, "")
|
||||
calculatedWidth := 0
|
||||
|
||||
for _, r := range strip(str) {
|
||||
calculatedWidth += calculateRunewidth(r, currentOpts)
|
||||
}
|
||||
|
||||
// Store in Cache
|
||||
widthCache.Add(key, calculatedWidth)
|
||||
return calculatedWidth
|
||||
}
|
||||
|
||||
// WidthNoCache calculates the visual width of a string without using the global cache.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// width := twdw.WidthNoCache("Hello\x1b[31mWorld") // Returns 10
|
||||
func WidthNoCache(str string) int {
|
||||
// This function's behavior is equivalent to a one-shot calculation
|
||||
// using the current global options. The WidthWithOptions function
|
||||
// does not interact with the cache, thus fulfilling the requirement.
|
||||
mu.Lock()
|
||||
opts := globalOptions
|
||||
mu.Unlock()
|
||||
return WidthWithOptions(str, opts)
|
||||
}
|
||||
|
||||
// WidthWithOptions calculates the visual width of a string with specific options,
|
||||
// bypassing the global settings and cache. This is useful for one-shot calculations
|
||||
// where global state is not desired.
|
||||
func WidthWithOptions(str string, opts Options) int {
|
||||
// stripped := ansi.ReplaceAllLiteralString(str, "")
|
||||
calculatedWidth := 0
|
||||
for _, r := range strip(str) {
|
||||
calculatedWidth += calculateRunewidth(r, opts)
|
||||
}
|
||||
return calculatedWidth
|
||||
}
|
||||
|
||||
// calculateRunewidth calculates the width of a single rune based on the provided options.
|
||||
// It applies narrow overrides for box drawing characters if configured and handles Tabs.
|
||||
func calculateRunewidth(r rune, opts Options) int {
|
||||
if opts.ForceNarrowBorders && isBoxDrawingChar(r) {
|
||||
return 1
|
||||
}
|
||||
|
||||
// Explicitly handle Tabinal to ensure tables have enough space
|
||||
// when TrimTab is Off.
|
||||
if IsTab(r) {
|
||||
return TabWidth()
|
||||
}
|
||||
|
||||
dwOpts := displaywidth.Options{EastAsianWidth: opts.EastAsianWidth}
|
||||
return dwOpts.Rune(r)
|
||||
}
|
||||
|
||||
// isBoxDrawingChar checks if a rune is within the Unicode Box Drawing range.
|
||||
func isBoxDrawingChar(r rune) bool {
|
||||
return r >= 0x2500 && r <= 0x257F
|
||||
}
|
||||
|
||||
func strip(s string) string {
|
||||
if strings.IndexByte(s, '\x1b') == -1 {
|
||||
return s
|
||||
}
|
||||
return ansi.ReplaceAllLiteralString(s, "")
|
||||
}
|
||||
+608
@@ -0,0 +1,608 @@
|
||||
package renderer
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/olekukonko/ll"
|
||||
"github.com/olekukonko/tablewriter/pkg/twwidth"
|
||||
|
||||
"github.com/olekukonko/tablewriter/tw"
|
||||
)
|
||||
|
||||
// Blueprint implements a primary table rendering engine with customizable borders and alignments.
|
||||
type Blueprint struct {
|
||||
config tw.Rendition // Rendering configuration for table borders and symbols
|
||||
logger *ll.Logger // Logger for debug trace messages
|
||||
w io.Writer
|
||||
}
|
||||
|
||||
// NewBlueprint creates a new Blueprint instance with optional custom configurations.
|
||||
func NewBlueprint(configs ...tw.Rendition) *Blueprint {
|
||||
// Initialize with default configuration
|
||||
cfg := defaultBlueprint()
|
||||
if len(configs) > 0 {
|
||||
userCfg := configs[0]
|
||||
// Override default borders if provided
|
||||
if userCfg.Borders.Left != 0 {
|
||||
cfg.Borders.Left = userCfg.Borders.Left
|
||||
}
|
||||
if userCfg.Borders.Right != 0 {
|
||||
cfg.Borders.Right = userCfg.Borders.Right
|
||||
}
|
||||
if userCfg.Borders.Top != 0 {
|
||||
cfg.Borders.Top = userCfg.Borders.Top
|
||||
}
|
||||
if userCfg.Borders.Bottom != 0 {
|
||||
cfg.Borders.Bottom = userCfg.Borders.Bottom
|
||||
}
|
||||
// Override symbols if provided
|
||||
if userCfg.Symbols != nil {
|
||||
cfg.Symbols = userCfg.Symbols
|
||||
}
|
||||
|
||||
// Merge user settings with default settings
|
||||
cfg.Settings = mergeSettings(cfg.Settings, userCfg.Settings)
|
||||
}
|
||||
return &Blueprint{config: cfg, logger: ll.New("blueprint").Disable()}
|
||||
}
|
||||
|
||||
// Close performs cleanup (no-op in this implementation).
|
||||
func (f *Blueprint) Close() error {
|
||||
f.logger.Debug("Blueprint.Close() called (no-op).")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Config returns the renderer's current configuration.
|
||||
func (f *Blueprint) Config() tw.Rendition {
|
||||
return f.config
|
||||
}
|
||||
|
||||
// Footer renders the table footer section with configured formatting.
|
||||
func (f *Blueprint) Footer(footers [][]string, ctx tw.Formatting) {
|
||||
f.logger.Debugf("Starting Footer render: IsSubRow=%v, Location=%v, Pos=%s", ctx.IsSubRow, ctx.Row.Location, ctx.Row.Position)
|
||||
// Render the footer line
|
||||
f.renderLine(ctx)
|
||||
f.logger.Debug("Completed Footer render")
|
||||
}
|
||||
|
||||
// Header renders the table header section with configured formatting.
|
||||
func (f *Blueprint) Header(headers [][]string, ctx tw.Formatting) {
|
||||
f.logger.Debugf("Starting Header render: IsSubRow=%v, Location=%v, Pos=%s, lines=%d, widths=%v",
|
||||
ctx.IsSubRow, ctx.Row.Location, ctx.Row.Position, len(ctx.Row.Current), ctx.Row.Widths)
|
||||
// Render the header line
|
||||
f.renderLine(ctx)
|
||||
f.logger.Debug("Completed Header render")
|
||||
}
|
||||
|
||||
// Line renders a full horizontal row line with junctions and segments.
|
||||
func (f *Blueprint) Line(ctx tw.Formatting) {
|
||||
// Initialize junction renderer
|
||||
jr := NewJunction(JunctionContext{
|
||||
Symbols: f.config.Symbols,
|
||||
Ctx: ctx,
|
||||
ColIdx: 0,
|
||||
Logger: f.logger,
|
||||
BorderTint: Tint{},
|
||||
SeparatorTint: Tint{},
|
||||
})
|
||||
|
||||
var line strings.Builder
|
||||
totalLineWidth := 0 // Track total display width
|
||||
// Get sorted column indices
|
||||
sortedKeys := ctx.Row.Widths.SortedKeys()
|
||||
numCols := 0
|
||||
if len(sortedKeys) > 0 {
|
||||
numCols = sortedKeys[len(sortedKeys)-1] + 1
|
||||
}
|
||||
|
||||
// Handle empty row case
|
||||
if numCols == 0 {
|
||||
prefix := tw.Empty
|
||||
suffix := tw.Empty
|
||||
if f.config.Borders.Left.Enabled() {
|
||||
prefix = jr.RenderLeft()
|
||||
}
|
||||
if f.config.Borders.Right.Enabled() {
|
||||
suffix = jr.RenderRight(-1)
|
||||
}
|
||||
if prefix != tw.Empty || suffix != tw.Empty {
|
||||
line.WriteString(prefix + suffix + tw.NewLine)
|
||||
totalLineWidth = twwidth.Width(prefix) + twwidth.Width(suffix)
|
||||
f.w.Write([]byte(line.String()))
|
||||
}
|
||||
f.logger.Debugf("Line: Handled empty row/widths case (total width %d)", totalLineWidth)
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate target total width based on data rows
|
||||
targetTotalWidth := 0
|
||||
for _, colIdx := range sortedKeys {
|
||||
targetTotalWidth += ctx.Row.Widths.Get(colIdx)
|
||||
}
|
||||
if f.config.Borders.Left.Enabled() {
|
||||
targetTotalWidth += twwidth.Width(f.config.Symbols.Column())
|
||||
}
|
||||
if f.config.Borders.Right.Enabled() {
|
||||
targetTotalWidth += twwidth.Width(f.config.Symbols.Column())
|
||||
}
|
||||
if f.config.Settings.Separators.BetweenColumns.Enabled() && len(sortedKeys) > 1 {
|
||||
targetTotalWidth += twwidth.Width(f.config.Symbols.Column()) * (len(sortedKeys) - 1)
|
||||
}
|
||||
|
||||
// Add left border if enabled
|
||||
leftBorderWidth := 0
|
||||
if f.config.Borders.Left.Enabled() {
|
||||
leftBorder := jr.RenderLeft()
|
||||
line.WriteString(leftBorder)
|
||||
leftBorderWidth = twwidth.Width(leftBorder)
|
||||
totalLineWidth += leftBorderWidth
|
||||
f.logger.Debugf("Line: Left border='%s' (f.width %d)", leftBorder, leftBorderWidth)
|
||||
}
|
||||
|
||||
visibleColIndices := make([]int, 0)
|
||||
// Calculate visible columns
|
||||
for _, colIdx := range sortedKeys {
|
||||
colWidth := ctx.Row.Widths.Get(colIdx)
|
||||
if colWidth > 0 {
|
||||
visibleColIndices = append(visibleColIndices, colIdx)
|
||||
}
|
||||
}
|
||||
|
||||
f.logger.Debugf("Line: sortedKeys=%v, Widths=%v, visibleColIndices=%v, targetTotalWidth=%d", sortedKeys, ctx.Row.Widths, visibleColIndices, targetTotalWidth)
|
||||
// Render each column segment
|
||||
for keyIndex, currentColIdx := range visibleColIndices {
|
||||
jr.colIdx = currentColIdx
|
||||
segment := jr.GetSegment()
|
||||
colWidth := ctx.Row.Widths.Get(currentColIdx)
|
||||
// Adjust colWidth to account for wider borders
|
||||
adjustedColWidth := colWidth
|
||||
if f.config.Borders.Left.Enabled() && keyIndex == 0 {
|
||||
adjustedColWidth -= leftBorderWidth - twwidth.Width(f.config.Symbols.Column())
|
||||
}
|
||||
if f.config.Borders.Right.Enabled() && keyIndex == len(visibleColIndices)-1 {
|
||||
rightBorderWidth := twwidth.Width(jr.RenderRight(currentColIdx))
|
||||
adjustedColWidth -= rightBorderWidth - twwidth.Width(f.config.Symbols.Column())
|
||||
}
|
||||
if adjustedColWidth < 0 {
|
||||
adjustedColWidth = 0
|
||||
}
|
||||
f.logger.Debugf("Line: colIdx=%d, segment='%s', adjusted colWidth=%d", currentColIdx, segment, adjustedColWidth)
|
||||
if segment == tw.Empty {
|
||||
spaces := strings.Repeat(tw.Space, adjustedColWidth)
|
||||
line.WriteString(spaces)
|
||||
totalLineWidth += adjustedColWidth
|
||||
f.logger.Debugf("Line: Rendered spaces='%s' (f.width %d) for col %d", spaces, adjustedColWidth, currentColIdx)
|
||||
} else {
|
||||
segmentWidth := twwidth.Width(segment)
|
||||
if segmentWidth == 0 {
|
||||
segmentWidth = 1 // Avoid division by zero
|
||||
f.logger.Warnf("Line: Segment='%s' has zero width, using 1", segment)
|
||||
}
|
||||
// Calculate how many full segments fit
|
||||
repeat := adjustedColWidth / segmentWidth
|
||||
if repeat < 1 && adjustedColWidth > 0 {
|
||||
repeat = 1
|
||||
}
|
||||
repeatedSegment := strings.Repeat(segment, repeat)
|
||||
actualWidth := twwidth.Width(repeatedSegment)
|
||||
if actualWidth > adjustedColWidth {
|
||||
// Truncate if too long
|
||||
repeatedSegment = twwidth.Truncate(repeatedSegment, adjustedColWidth)
|
||||
actualWidth = twwidth.Width(repeatedSegment)
|
||||
f.logger.Debugf("Line: Truncated segment='%s' to width %d", repeatedSegment, actualWidth)
|
||||
} else if actualWidth < adjustedColWidth {
|
||||
// Pad with segment character to match adjustedColWidth
|
||||
remainingWidth := adjustedColWidth - actualWidth
|
||||
for i := 0; i < remainingWidth/segmentWidth; i++ {
|
||||
repeatedSegment += segment
|
||||
}
|
||||
actualWidth = twwidth.Width(repeatedSegment)
|
||||
if actualWidth < adjustedColWidth {
|
||||
repeatedSegment = tw.PadRight(repeatedSegment, tw.Space, adjustedColWidth)
|
||||
actualWidth = adjustedColWidth
|
||||
f.logger.Debugf("Line: Padded segment with spaces='%s' to width %d", repeatedSegment, actualWidth)
|
||||
}
|
||||
f.logger.Debugf("Line: Padded segment='%s' to width %d", repeatedSegment, actualWidth)
|
||||
}
|
||||
line.WriteString(repeatedSegment)
|
||||
totalLineWidth += actualWidth
|
||||
f.logger.Debugf("Line: Rendered segment='%s' (f.width %d) for col %d", repeatedSegment, actualWidth, currentColIdx)
|
||||
}
|
||||
|
||||
// Add junction between columns if not the last column
|
||||
isLast := keyIndex == len(visibleColIndices)-1
|
||||
if !isLast && f.config.Settings.Separators.BetweenColumns.Enabled() {
|
||||
nextColIdx := visibleColIndices[keyIndex+1]
|
||||
junction := jr.RenderJunction(currentColIdx, nextColIdx)
|
||||
// Use center symbol (❀) or column separator (|) to match data rows
|
||||
if twwidth.Width(junction) != twwidth.Width(f.config.Symbols.Column()) {
|
||||
junction = f.config.Symbols.Center()
|
||||
if twwidth.Width(junction) != twwidth.Width(f.config.Symbols.Column()) {
|
||||
junction = f.config.Symbols.Column()
|
||||
}
|
||||
}
|
||||
junctionWidth := twwidth.Width(junction)
|
||||
line.WriteString(junction)
|
||||
totalLineWidth += junctionWidth
|
||||
f.logger.Debugf("Line: Junction between %d and %d: '%s' (f.width %d)", currentColIdx, nextColIdx, junction, junctionWidth)
|
||||
}
|
||||
}
|
||||
|
||||
// Add right border
|
||||
rightBorderWidth := 0
|
||||
if f.config.Borders.Right.Enabled() && len(visibleColIndices) > 0 {
|
||||
lastIdx := visibleColIndices[len(visibleColIndices)-1]
|
||||
rightBorder := jr.RenderRight(lastIdx)
|
||||
rightBorderWidth = twwidth.Width(rightBorder)
|
||||
line.WriteString(rightBorder)
|
||||
totalLineWidth += rightBorderWidth
|
||||
f.logger.Debugf("Line: Right border='%s' (f.width %d)", rightBorder, rightBorderWidth)
|
||||
}
|
||||
|
||||
// Write the final line
|
||||
line.WriteString(tw.NewLine)
|
||||
f.w.Write([]byte(line.String()))
|
||||
f.logger.Debugf("Line rendered: '%s' (total width %d, target %d)", strings.TrimSuffix(line.String(), tw.NewLine), totalLineWidth, targetTotalWidth)
|
||||
}
|
||||
|
||||
// Logger sets the logger for the Blueprint instance.
|
||||
func (f *Blueprint) Logger(logger *ll.Logger) {
|
||||
f.logger = logger.Namespace("blueprint")
|
||||
}
|
||||
|
||||
// Row renders a table data row with configured formatting.
|
||||
func (f *Blueprint) Row(row []string, ctx tw.Formatting) {
|
||||
f.logger.Debugf("Starting Row render: IsSubRow=%v, Location=%v, Pos=%s, hasFooter=%v",
|
||||
ctx.IsSubRow, ctx.Row.Location, ctx.Row.Position, ctx.HasFooter)
|
||||
|
||||
// Render the row line
|
||||
f.renderLine(ctx)
|
||||
f.logger.Debug("Completed Row render")
|
||||
}
|
||||
|
||||
// Start initializes the rendering process (no-op in this implementation).
|
||||
func (f *Blueprint) Start(w io.Writer) error {
|
||||
f.w = w
|
||||
f.logger.Debug("Blueprint.Start() called (no-op).")
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatCell formats a cell's content with specified width, padding, and alignment, returning an empty string if width is non-positive.
|
||||
func (f *Blueprint) formatCell(content string, width int, padding tw.Padding, align tw.Align) string {
|
||||
if width <= 0 {
|
||||
return tw.Empty
|
||||
}
|
||||
|
||||
f.logger.Debugf("Formatting cell: content='%s', width=%d, align=%s, padding={L:'%s' R:'%s'}",
|
||||
content, width, align, padding.Left, padding.Right)
|
||||
|
||||
// Calculate display width of content
|
||||
runeWidth := twwidth.Width(content)
|
||||
|
||||
// Set default padding characters
|
||||
leftPadChar := padding.Left
|
||||
rightPadChar := padding.Right
|
||||
|
||||
// if f.config.Settings.Cushion.Enabled() || f.config.Settings.Cushion.Default() {
|
||||
// if leftPadChar == tw.Empty {
|
||||
// leftPadChar = tw.Space
|
||||
// }
|
||||
// if rightPadChar == tw.Empty {
|
||||
// rightPadChar = tw.Space
|
||||
// }
|
||||
//}
|
||||
|
||||
// Calculate padding widths
|
||||
padLeftWidth := twwidth.Width(leftPadChar)
|
||||
padRightWidth := twwidth.Width(rightPadChar)
|
||||
|
||||
// Calculate available width for content
|
||||
availableContentWidth := max(width-padLeftWidth-padRightWidth, 0)
|
||||
f.logger.Debugf("Available content width: %d", availableContentWidth)
|
||||
|
||||
// Truncate content if it exceeds available width
|
||||
if runeWidth > availableContentWidth {
|
||||
content = twwidth.Truncate(content, availableContentWidth)
|
||||
runeWidth = twwidth.Width(content)
|
||||
f.logger.Debugf("Truncated content to fit %d: '%s' (new width %d)", availableContentWidth, content, runeWidth)
|
||||
}
|
||||
|
||||
// Calculate total padding needed
|
||||
totalPaddingWidth := max(width-runeWidth, 0)
|
||||
f.logger.Debugf("Total padding width: %d", totalPaddingWidth)
|
||||
|
||||
var result strings.Builder
|
||||
var leftPaddingWidth, rightPaddingWidth int
|
||||
|
||||
// Apply alignment and padding
|
||||
switch align {
|
||||
case tw.AlignLeft:
|
||||
result.WriteString(leftPadChar)
|
||||
result.WriteString(content)
|
||||
rightPaddingWidth = totalPaddingWidth - padLeftWidth
|
||||
if rightPaddingWidth > 0 {
|
||||
padChar := rightPadChar
|
||||
if padChar == tw.Empty {
|
||||
padChar = tw.Space
|
||||
}
|
||||
result.WriteString(tw.PadRight(tw.Empty, padChar, rightPaddingWidth))
|
||||
f.logger.Debugf("Applied right padding: '%s' for %d width", padChar, rightPaddingWidth)
|
||||
}
|
||||
case tw.AlignRight:
|
||||
leftPaddingWidth = totalPaddingWidth - padRightWidth
|
||||
if leftPaddingWidth > 0 {
|
||||
padChar := leftPadChar
|
||||
if padChar == tw.Empty {
|
||||
padChar = tw.Space
|
||||
}
|
||||
result.WriteString(tw.PadLeft(tw.Empty, padChar, leftPaddingWidth))
|
||||
f.logger.Debugf("Applied left padding: '%s' for %d width", padChar, leftPaddingWidth)
|
||||
}
|
||||
result.WriteString(content)
|
||||
result.WriteString(rightPadChar)
|
||||
case tw.AlignCenter:
|
||||
leftPaddingWidth = (totalPaddingWidth-padLeftWidth-padRightWidth)/2 + padLeftWidth
|
||||
rightPaddingWidth = totalPaddingWidth - leftPaddingWidth
|
||||
if leftPaddingWidth > padLeftWidth {
|
||||
padChar := leftPadChar
|
||||
if padChar == tw.Empty {
|
||||
padChar = tw.Space
|
||||
}
|
||||
result.WriteString(tw.PadLeft(tw.Empty, padChar, leftPaddingWidth-padLeftWidth))
|
||||
f.logger.Debugf("Applied left centering padding: '%s' for %d width", padChar, leftPaddingWidth-padLeftWidth)
|
||||
}
|
||||
result.WriteString(leftPadChar)
|
||||
result.WriteString(content)
|
||||
result.WriteString(rightPadChar)
|
||||
if rightPaddingWidth > padRightWidth {
|
||||
padChar := rightPadChar
|
||||
if padChar == tw.Empty {
|
||||
padChar = tw.Space
|
||||
}
|
||||
result.WriteString(tw.PadRight(tw.Empty, padChar, rightPaddingWidth-padRightWidth))
|
||||
f.logger.Debugf("Applied right centering padding: '%s' for %d width", padChar, rightPaddingWidth-padRightWidth)
|
||||
}
|
||||
default:
|
||||
// Default to left alignment
|
||||
result.WriteString(leftPadChar)
|
||||
result.WriteString(content)
|
||||
rightPaddingWidth = totalPaddingWidth - padLeftWidth
|
||||
if rightPaddingWidth > 0 {
|
||||
padChar := rightPadChar
|
||||
if padChar == tw.Empty {
|
||||
padChar = tw.Space
|
||||
}
|
||||
result.WriteString(tw.PadRight(tw.Empty, padChar, rightPaddingWidth))
|
||||
f.logger.Debugf("Applied right padding: '%s' for %d width", padChar, rightPaddingWidth)
|
||||
}
|
||||
}
|
||||
|
||||
output := result.String()
|
||||
finalWidth := twwidth.Width(output)
|
||||
// Adjust output to match target width
|
||||
if finalWidth > width {
|
||||
output = twwidth.Truncate(output, width)
|
||||
f.logger.Debugf("formatCell: Truncated output to width %d", width)
|
||||
} else if finalWidth < width {
|
||||
output = tw.PadRight(output, tw.Space, width)
|
||||
f.logger.Debugf("formatCell: Padded output to meet width %d", width)
|
||||
}
|
||||
|
||||
// Log warning if final width doesn't match target
|
||||
if f.logger.Enabled() && twwidth.Width(output) != width {
|
||||
f.logger.Debugf("formatCell Warning: Final width %d does not match target %d for result '%s'",
|
||||
twwidth.Width(output), width, output)
|
||||
}
|
||||
|
||||
f.logger.Debugf("Formatted cell final result: '%s' (target width %d)", output, width)
|
||||
return output
|
||||
}
|
||||
|
||||
// renderLine renders a single line (header, row, or footer) with borders, separators, and merge handling.
|
||||
func (f *Blueprint) renderLine(ctx tw.Formatting) {
|
||||
// Get sorted column indices
|
||||
sortedKeys := ctx.Row.Widths.SortedKeys()
|
||||
numCols := 0
|
||||
if len(sortedKeys) > 0 {
|
||||
numCols = sortedKeys[len(sortedKeys)-1] + 1
|
||||
}
|
||||
|
||||
// Set column separator and borders
|
||||
columnSeparator := f.config.Symbols.Column()
|
||||
prefix := tw.Empty
|
||||
if f.config.Borders.Left.Enabled() {
|
||||
prefix = columnSeparator
|
||||
}
|
||||
suffix := tw.Empty
|
||||
if f.config.Borders.Right.Enabled() {
|
||||
suffix = columnSeparator
|
||||
}
|
||||
|
||||
var output strings.Builder
|
||||
totalLineWidth := 0 // Track total display width
|
||||
if prefix != tw.Empty {
|
||||
output.WriteString(prefix)
|
||||
totalLineWidth += twwidth.Width(prefix)
|
||||
f.logger.Debugf("renderLine: Prefix='%s' (f.width %d)", prefix, twwidth.Width(prefix))
|
||||
}
|
||||
|
||||
colIndex := 0
|
||||
separatorDisplayWidth := 0
|
||||
if f.config.Settings.Separators.BetweenColumns.Enabled() {
|
||||
separatorDisplayWidth = twwidth.Width(columnSeparator)
|
||||
}
|
||||
|
||||
// Process each column
|
||||
for colIndex < numCols {
|
||||
visualWidth := ctx.Row.Widths.Get(colIndex)
|
||||
cellCtx, ok := ctx.Row.Current[colIndex]
|
||||
isHMergeStart := ok && cellCtx.Merge.Horizontal.Present && cellCtx.Merge.Horizontal.Start
|
||||
if visualWidth == 0 && !isHMergeStart {
|
||||
f.logger.Debugf("renderLine: Skipping col %d (zero width, not HMerge start)", colIndex)
|
||||
colIndex++
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine if a separator is needed
|
||||
shouldAddSeparator := false
|
||||
if colIndex > 0 && f.config.Settings.Separators.BetweenColumns.Enabled() {
|
||||
prevWidth := ctx.Row.Widths.Get(colIndex - 1)
|
||||
prevCellCtx, prevOk := ctx.Row.Current[colIndex-1]
|
||||
prevIsHMergeEnd := prevOk && prevCellCtx.Merge.Horizontal.Present && prevCellCtx.Merge.Horizontal.End
|
||||
if (prevWidth > 0 || prevIsHMergeEnd) && (!ok || (!cellCtx.Merge.Horizontal.Present || cellCtx.Merge.Horizontal.Start)) {
|
||||
shouldAddSeparator = true
|
||||
}
|
||||
}
|
||||
if shouldAddSeparator {
|
||||
output.WriteString(columnSeparator)
|
||||
totalLineWidth += separatorDisplayWidth
|
||||
f.logger.Debugf("renderLine: Added separator '%s' before col %d (f.width %d)", columnSeparator, colIndex, separatorDisplayWidth)
|
||||
} else if colIndex > 0 {
|
||||
f.logger.Debugf("renderLine: Skipped separator before col %d due to zero-width prev col or HMerge continuation", colIndex)
|
||||
}
|
||||
|
||||
// Handle merged cells
|
||||
span := 1
|
||||
if isHMergeStart {
|
||||
span = cellCtx.Merge.Horizontal.Span
|
||||
if ctx.Row.Position == tw.Row {
|
||||
dynamicTotalWidth := 0
|
||||
for k := 0; k < span && colIndex+k < numCols; k++ {
|
||||
normWidth := max(ctx.NormalizedWidths.Get(colIndex+k), 0)
|
||||
dynamicTotalWidth += normWidth
|
||||
if k > 0 && separatorDisplayWidth > 0 && ctx.NormalizedWidths.Get(colIndex+k) > 0 {
|
||||
dynamicTotalWidth += separatorDisplayWidth
|
||||
}
|
||||
}
|
||||
visualWidth = dynamicTotalWidth
|
||||
f.logger.Debugf("renderLine: Row HMerge col %d, span %d, dynamic visualWidth %d", colIndex, span, visualWidth)
|
||||
} else {
|
||||
visualWidth = ctx.Row.Widths.Get(colIndex)
|
||||
f.logger.Debugf("renderLine: H/F HMerge col %d, span %d, pre-adjusted visualWidth %d", colIndex, span, visualWidth)
|
||||
}
|
||||
} else {
|
||||
visualWidth = ctx.Row.Widths.Get(colIndex)
|
||||
f.logger.Debugf("renderLine: Regular col %d, visualWidth %d", colIndex, visualWidth)
|
||||
}
|
||||
if visualWidth < 0 {
|
||||
visualWidth = 0
|
||||
}
|
||||
|
||||
// Skip processing for non-start merged cells
|
||||
if ok && cellCtx.Merge.Horizontal.Present && !cellCtx.Merge.Horizontal.Start {
|
||||
f.logger.Debugf("renderLine: Skipping col %d processing (part of HMerge)", colIndex)
|
||||
colIndex++
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle empty cell context
|
||||
if !ok {
|
||||
if visualWidth > 0 {
|
||||
spaces := strings.Repeat(tw.Space, visualWidth)
|
||||
output.WriteString(spaces)
|
||||
totalLineWidth += visualWidth
|
||||
f.logger.Debugf("renderLine: No cell context for col %d, writing %d spaces (f.width %d)", colIndex, visualWidth, visualWidth)
|
||||
} else {
|
||||
f.logger.Debugf("renderLine: No cell context for col %d, visualWidth is 0, writing nothing", colIndex)
|
||||
}
|
||||
colIndex += span
|
||||
continue
|
||||
}
|
||||
|
||||
// Set cell padding and alignment
|
||||
padding := cellCtx.Padding
|
||||
align := cellCtx.Align
|
||||
switch align {
|
||||
case tw.AlignNone:
|
||||
switch ctx.Row.Position {
|
||||
case tw.Header:
|
||||
align = tw.AlignCenter
|
||||
case tw.Footer:
|
||||
align = tw.AlignRight
|
||||
default:
|
||||
align = tw.AlignLeft
|
||||
}
|
||||
f.logger.Debugf("renderLine: col %d (data: '%s') using renderer default align '%s' for position %s.", colIndex, cellCtx.Data, align, ctx.Row.Position)
|
||||
case tw.Skip:
|
||||
switch ctx.Row.Position {
|
||||
case tw.Header:
|
||||
align = tw.AlignCenter
|
||||
case tw.Footer:
|
||||
align = tw.AlignRight
|
||||
default:
|
||||
align = tw.AlignLeft
|
||||
}
|
||||
f.logger.Debugf("renderLine: col %d (data: '%s') cellCtx.Align was Skip/empty, falling back to basic default '%s'.", colIndex, cellCtx.Data, align)
|
||||
}
|
||||
|
||||
isTotalPattern := false
|
||||
|
||||
// Case-insensitive check for "total"
|
||||
if isHMergeStart && colIndex > 0 {
|
||||
if prevCellCtx, ok := ctx.Row.Current[colIndex-1]; ok {
|
||||
if strings.Contains(strings.ToLower(prevCellCtx.Data), "total") {
|
||||
isTotalPattern = true
|
||||
f.logger.Debugf("renderLine: total pattern in row in %d", colIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get the alignment from the configuration
|
||||
align = cellCtx.Align
|
||||
|
||||
// Override alignment for footer merged cells
|
||||
if (ctx.Row.Position == tw.Footer && isHMergeStart) || isTotalPattern {
|
||||
if align == tw.AlignNone {
|
||||
f.logger.Debugf("renderLine: Applying AlignRight HMerge/TOTAL override for Footer col %d. Original/default align was: %s", colIndex, align)
|
||||
align = tw.AlignRight
|
||||
}
|
||||
}
|
||||
|
||||
// Handle vertical/hierarchical merges
|
||||
cellData := cellCtx.Data
|
||||
if (cellCtx.Merge.Vertical.Present && !cellCtx.Merge.Vertical.Start) ||
|
||||
(cellCtx.Merge.Hierarchical.Present && !cellCtx.Merge.Hierarchical.Start) {
|
||||
cellData = tw.Empty
|
||||
f.logger.Debugf("renderLine: Blanked data for col %d (non-start V/Hierarchical)", colIndex)
|
||||
}
|
||||
|
||||
// Format and render the cell
|
||||
formattedCell := f.formatCell(cellData, visualWidth, padding, align)
|
||||
if len(formattedCell) > 0 {
|
||||
output.WriteString(formattedCell)
|
||||
cellWidth := twwidth.Width(formattedCell)
|
||||
totalLineWidth += cellWidth
|
||||
f.logger.Debugf("renderLine: Rendered col %d, formattedCell='%s' (f.width %d), totalLineWidth=%d", colIndex, formattedCell, cellWidth, totalLineWidth)
|
||||
}
|
||||
|
||||
// Log rendering details
|
||||
if isHMergeStart {
|
||||
f.logger.Debugf("renderLine: Rendered HMerge START col %d (span %d, visualWidth %d, align %v): '%s'",
|
||||
colIndex, span, visualWidth, align, formattedCell)
|
||||
} else {
|
||||
f.logger.Debugf("renderLine: Rendered regular col %d (visualWidth %d, align %v): '%s'",
|
||||
colIndex, visualWidth, align, formattedCell)
|
||||
}
|
||||
colIndex += span
|
||||
}
|
||||
|
||||
// Add suffix and adjust total width
|
||||
if output.Len() > len(prefix) || f.config.Borders.Right.Enabled() {
|
||||
output.WriteString(suffix)
|
||||
totalLineWidth += twwidth.Width(suffix)
|
||||
f.logger.Debugf("renderLine: Suffix='%s' (f.width %d)", suffix, twwidth.Width(suffix))
|
||||
}
|
||||
output.WriteString(tw.NewLine)
|
||||
f.w.Write([]byte(output.String()))
|
||||
f.logger.Debugf("renderLine: Final rendered line: '%s' (total width %d)", strings.TrimSuffix(output.String(), tw.NewLine), totalLineWidth)
|
||||
}
|
||||
|
||||
// Rendition updates the Blueprint's configuration.
|
||||
func (f *Blueprint) Rendition(config tw.Rendition) {
|
||||
f.config = mergeRendition(f.config, config)
|
||||
f.logger.Debugf("Blueprint.Rendition updated. New config: %+v", f.config)
|
||||
}
|
||||
|
||||
// Ensure Blueprint implements tw.Renditioning
|
||||
var _ tw.Renditioning = (*Blueprint)(nil)
|
||||
+702
@@ -0,0 +1,702 @@
|
||||
package renderer
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/olekukonko/ll"
|
||||
"github.com/olekukonko/ll/lh"
|
||||
"github.com/olekukonko/tablewriter/pkg/twwidth"
|
||||
|
||||
"github.com/olekukonko/tablewriter/tw"
|
||||
)
|
||||
|
||||
// ColorizedConfig holds configuration for the Colorized table renderer.
|
||||
type ColorizedConfig struct {
|
||||
Borders tw.Border // Border visibility settings
|
||||
Settings tw.Settings // Rendering behavior settings (e.g., separators, whitespace)
|
||||
Header Tint // Colors for header cells
|
||||
Column Tint // Colors for row cells
|
||||
Footer Tint // Colors for footer cells
|
||||
Border Tint // Colors for borders and lines
|
||||
Separator Tint // Colors for column separators
|
||||
Symbols tw.Symbols // Symbols for table drawing (e.g., corners, lines)
|
||||
}
|
||||
|
||||
// Colorized renders colored ASCII tables with customizable borders, colors, and alignments.
|
||||
type Colorized struct {
|
||||
config ColorizedConfig // Renderer configuration
|
||||
trace []string // Debug trace messages
|
||||
newLine string // Newline character
|
||||
defaultAlign map[tw.Position]tw.Align // Default alignments for header, row, and footer
|
||||
logger *ll.Logger // Logger for debug messages
|
||||
w io.Writer
|
||||
}
|
||||
|
||||
// NewColorized creates a Colorized renderer with the specified configuration, falling back to defaults if none provided.
|
||||
// Only the first config is used if multiple are passed.
|
||||
func NewColorized(configs ...ColorizedConfig) *Colorized {
|
||||
// Initialize with default configuration
|
||||
baseCfg := defaultColorized()
|
||||
|
||||
if len(configs) > 0 {
|
||||
userCfg := configs[0]
|
||||
|
||||
// Override border settings if provided
|
||||
if userCfg.Borders.Left != 0 {
|
||||
baseCfg.Borders.Left = userCfg.Borders.Left
|
||||
}
|
||||
if userCfg.Borders.Right != 0 {
|
||||
baseCfg.Borders.Right = userCfg.Borders.Right
|
||||
}
|
||||
if userCfg.Borders.Top != 0 {
|
||||
baseCfg.Borders.Top = userCfg.Borders.Top
|
||||
}
|
||||
if userCfg.Borders.Bottom != 0 {
|
||||
baseCfg.Borders.Bottom = userCfg.Borders.Bottom
|
||||
}
|
||||
|
||||
// Merge separator and line settings
|
||||
baseCfg.Settings.Separators = mergeSeparators(baseCfg.Settings.Separators, userCfg.Settings.Separators)
|
||||
baseCfg.Settings.Lines = mergeLines(baseCfg.Settings.Lines, userCfg.Settings.Lines)
|
||||
|
||||
// Override compact mode if specified
|
||||
if userCfg.Settings.CompactMode != 0 {
|
||||
baseCfg.Settings.CompactMode = userCfg.Settings.CompactMode
|
||||
}
|
||||
|
||||
// Override color settings for various table elements
|
||||
if len(userCfg.Header.FG) > 0 || len(userCfg.Header.BG) > 0 || userCfg.Header.Columns != nil {
|
||||
baseCfg.Header = userCfg.Header
|
||||
}
|
||||
if len(userCfg.Column.FG) > 0 || len(userCfg.Column.BG) > 0 || userCfg.Column.Columns != nil {
|
||||
baseCfg.Column = userCfg.Column
|
||||
}
|
||||
if len(userCfg.Footer.FG) > 0 || len(userCfg.Footer.BG) > 0 || userCfg.Footer.Columns != nil {
|
||||
baseCfg.Footer = userCfg.Footer
|
||||
}
|
||||
if len(userCfg.Border.FG) > 0 || len(userCfg.Border.BG) > 0 || userCfg.Border.Columns != nil {
|
||||
baseCfg.Border = userCfg.Border
|
||||
}
|
||||
if len(userCfg.Separator.FG) > 0 || len(userCfg.Separator.BG) > 0 || userCfg.Separator.Columns != nil {
|
||||
baseCfg.Separator = userCfg.Separator
|
||||
}
|
||||
|
||||
// Override symbols if provided
|
||||
if userCfg.Symbols != nil {
|
||||
baseCfg.Symbols = userCfg.Symbols
|
||||
}
|
||||
}
|
||||
|
||||
cfg := baseCfg
|
||||
// Ensure symbols are initialized
|
||||
if cfg.Symbols == nil {
|
||||
cfg.Symbols = tw.NewSymbols(tw.StyleLight)
|
||||
}
|
||||
|
||||
// Initialize the Colorized renderer
|
||||
f := &Colorized{
|
||||
config: cfg,
|
||||
newLine: tw.NewLine,
|
||||
defaultAlign: map[tw.Position]tw.Align{
|
||||
tw.Header: tw.AlignCenter,
|
||||
tw.Row: tw.AlignLeft,
|
||||
tw.Footer: tw.AlignRight,
|
||||
},
|
||||
logger: ll.New("colorized", ll.WithHandler(lh.NewMemoryHandler())).Disable(),
|
||||
}
|
||||
// Log initialization details
|
||||
f.logger.Debugf("Initialized Colorized renderer with symbols: Center=%q, Row=%q, Column=%q", f.config.Symbols.Center(), f.config.Symbols.Row(), f.config.Symbols.Column())
|
||||
f.logger.Debugf("Final ColorizedConfig.Settings.Lines: %+v", f.config.Settings.Lines)
|
||||
f.logger.Debugf("Final ColorizedConfig.Borders: %+v", f.config.Borders)
|
||||
return f
|
||||
}
|
||||
|
||||
// Close performs cleanup (no-op in this implementation).
|
||||
func (c *Colorized) Close() error {
|
||||
c.logger.Debug("Colorized.Close() called (no-op).")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Config returns the renderer's configuration as a Rendition.
|
||||
func (c *Colorized) Config() tw.Rendition {
|
||||
return tw.Rendition{
|
||||
Borders: c.config.Borders,
|
||||
Settings: c.config.Settings,
|
||||
Symbols: c.config.Symbols,
|
||||
Streaming: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Debug returns the accumulated debug trace messages.
|
||||
func (c *Colorized) Debug() []string {
|
||||
return c.trace
|
||||
}
|
||||
|
||||
// Footer renders the table footer with configured colors and formatting.
|
||||
func (c *Colorized) Footer(footers [][]string, ctx tw.Formatting) {
|
||||
c.logger.Debugf("Starting Footer render: IsSubRow=%v, Location=%v, Pos=%s",
|
||||
ctx.IsSubRow, ctx.Row.Location, ctx.Row.Position)
|
||||
|
||||
// Check if there are footers to render
|
||||
if len(footers) == 0 || len(footers[0]) == 0 {
|
||||
c.logger.Debug("Footer: No footers to render")
|
||||
return
|
||||
}
|
||||
|
||||
// Render the footer line
|
||||
c.renderLine(ctx, footers[0], c.config.Footer)
|
||||
c.logger.Debug("Completed Footer render")
|
||||
}
|
||||
|
||||
// Header renders the table header with configured colors and formatting.
|
||||
func (c *Colorized) Header(headers [][]string, ctx tw.Formatting) {
|
||||
c.logger.Debugf("Starting Header render: IsSubRow=%v, Location=%v, Pos=%s, lines=%d, widths=%v",
|
||||
ctx.IsSubRow, ctx.Row.Location, ctx.Row.Position, len(headers), ctx.Row.Widths)
|
||||
|
||||
// Check if there are headers to render
|
||||
if len(headers) == 0 || len(headers[0]) == 0 {
|
||||
c.logger.Debug("Header: No headers to render")
|
||||
return
|
||||
}
|
||||
|
||||
// Render the header line
|
||||
c.renderLine(ctx, headers[0], c.config.Header)
|
||||
c.logger.Debug("Completed Header render")
|
||||
}
|
||||
|
||||
// Line renders a horizontal row line with colored junctions and segments, skipping zero-width columns.
|
||||
func (c *Colorized) Line(ctx tw.Formatting) {
|
||||
c.logger.Debugf("Line: Starting with Level=%v, Location=%v, IsSubRow=%v, Widths=%v", ctx.Level, ctx.Row.Location, ctx.IsSubRow, ctx.Row.Widths)
|
||||
|
||||
// Initialize junction renderer
|
||||
jr := NewJunction(JunctionContext{
|
||||
Symbols: c.config.Symbols,
|
||||
Ctx: ctx,
|
||||
ColIdx: 0,
|
||||
BorderTint: c.config.Border,
|
||||
SeparatorTint: c.config.Separator,
|
||||
Logger: c.logger,
|
||||
})
|
||||
|
||||
var line strings.Builder
|
||||
|
||||
// Get sorted column indices and filter out zero-width columns
|
||||
allSortedKeys := ctx.Row.Widths.SortedKeys()
|
||||
effectiveKeys := []int{}
|
||||
keyWidthMap := make(map[int]int)
|
||||
|
||||
for _, k := range allSortedKeys {
|
||||
width := ctx.Row.Widths.Get(k)
|
||||
keyWidthMap[k] = width
|
||||
if width > 0 {
|
||||
effectiveKeys = append(effectiveKeys, k)
|
||||
}
|
||||
}
|
||||
c.logger.Debugf("Line: All keys=%v, Effective keys (width>0)=%v", allSortedKeys, effectiveKeys)
|
||||
|
||||
// Handle case with no effective columns
|
||||
if len(effectiveKeys) == 0 {
|
||||
prefix := tw.Empty
|
||||
suffix := tw.Empty
|
||||
if c.config.Borders.Left.Enabled() {
|
||||
prefix = jr.RenderLeft()
|
||||
}
|
||||
if c.config.Borders.Right.Enabled() {
|
||||
originalLastColIdx := -1
|
||||
if len(allSortedKeys) > 0 {
|
||||
originalLastColIdx = allSortedKeys[len(allSortedKeys)-1]
|
||||
}
|
||||
suffix = jr.RenderRight(originalLastColIdx)
|
||||
}
|
||||
if prefix != tw.Empty || suffix != tw.Empty {
|
||||
line.WriteString(prefix + suffix + tw.NewLine)
|
||||
c.w.Write([]byte(line.String()))
|
||||
}
|
||||
c.logger.Debug("Line: Handled empty row/widths case (no effective keys)")
|
||||
return
|
||||
}
|
||||
|
||||
// Add left border if enabled
|
||||
if c.config.Borders.Left.Enabled() {
|
||||
line.WriteString(jr.RenderLeft())
|
||||
}
|
||||
|
||||
// Render segments for each effective column
|
||||
for keyIndex, currentColIdx := range effectiveKeys {
|
||||
jr.colIdx = currentColIdx
|
||||
segment := jr.GetSegment()
|
||||
colWidth := keyWidthMap[currentColIdx]
|
||||
c.logger.Debugf("Line: Drawing segment for Effective colIdx=%d, segment='%s', width=%d", currentColIdx, segment, colWidth)
|
||||
|
||||
if segment == tw.Empty {
|
||||
line.WriteString(strings.Repeat(tw.Space, colWidth))
|
||||
} else {
|
||||
// Calculate how many times to repeat the segment
|
||||
segmentWidth := twwidth.Width(segment)
|
||||
if segmentWidth <= 0 {
|
||||
segmentWidth = 1
|
||||
}
|
||||
repeat := 0
|
||||
if colWidth > 0 && segmentWidth > 0 {
|
||||
repeat = colWidth / segmentWidth
|
||||
}
|
||||
drawnSegment := strings.Repeat(segment, repeat)
|
||||
line.WriteString(drawnSegment)
|
||||
|
||||
// Adjust for width discrepancies
|
||||
actualDrawnWidth := twwidth.Width(drawnSegment)
|
||||
if actualDrawnWidth < colWidth {
|
||||
missingWidth := colWidth - actualDrawnWidth
|
||||
spaces := strings.Repeat(tw.Space, missingWidth)
|
||||
if len(c.config.Border.BG) > 0 {
|
||||
line.WriteString(Tint{BG: c.config.Border.BG}.Apply(spaces))
|
||||
} else {
|
||||
line.WriteString(spaces)
|
||||
}
|
||||
c.logger.Debugf("Line: colIdx=%d corrected segment width, added %d spaces", currentColIdx, missingWidth)
|
||||
} else if actualDrawnWidth > colWidth {
|
||||
c.logger.Debugf("Line: WARNING colIdx=%d segment draw width %d > target %d", currentColIdx, actualDrawnWidth, colWidth)
|
||||
}
|
||||
}
|
||||
|
||||
// Add junction between columns if not the last visible column
|
||||
isLastVisible := keyIndex == len(effectiveKeys)-1
|
||||
if !isLastVisible && c.config.Settings.Separators.BetweenColumns.Enabled() {
|
||||
nextVisibleColIdx := effectiveKeys[keyIndex+1]
|
||||
originalPrecedingCol := -1
|
||||
foundCurrent := false
|
||||
for _, k := range allSortedKeys {
|
||||
if k == currentColIdx {
|
||||
foundCurrent = true
|
||||
}
|
||||
if foundCurrent && k < nextVisibleColIdx {
|
||||
originalPrecedingCol = k
|
||||
}
|
||||
if k >= nextVisibleColIdx {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if originalPrecedingCol != -1 {
|
||||
jr.colIdx = originalPrecedingCol
|
||||
junction := jr.RenderJunction(originalPrecedingCol, nextVisibleColIdx)
|
||||
c.logger.Debugf("Line: Junction between visible %d (orig preceding %d) and next visible %d: '%s'", currentColIdx, originalPrecedingCol, nextVisibleColIdx, junction)
|
||||
line.WriteString(junction)
|
||||
} else {
|
||||
c.logger.Debugf("Line: Could not determine original preceding column for junction before visible %d", nextVisibleColIdx)
|
||||
line.WriteString(c.config.Separator.Apply(jr.sym.Center()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add right border if enabled
|
||||
if c.config.Borders.Right.Enabled() {
|
||||
originalLastColIdx := -1
|
||||
if len(allSortedKeys) > 0 {
|
||||
originalLastColIdx = allSortedKeys[len(allSortedKeys)-1]
|
||||
}
|
||||
jr.colIdx = originalLastColIdx
|
||||
line.WriteString(jr.RenderRight(originalLastColIdx))
|
||||
}
|
||||
|
||||
// Write the final line
|
||||
line.WriteString(c.newLine)
|
||||
c.w.Write([]byte(line.String()))
|
||||
c.logger.Debugf("Line rendered: %s", strings.TrimSuffix(line.String(), c.newLine))
|
||||
}
|
||||
|
||||
// Logger sets the logger for the Colorized instance.
|
||||
func (c *Colorized) Logger(logger *ll.Logger) {
|
||||
c.logger = logger.Namespace("colorized")
|
||||
}
|
||||
|
||||
// Reset clears the renderer's internal state, including debug traces.
|
||||
func (c *Colorized) Reset() {
|
||||
c.trace = nil
|
||||
c.logger.Debugf("Reset: Cleared debug trace")
|
||||
}
|
||||
|
||||
// Row renders a table data row with configured colors and formatting.
|
||||
func (c *Colorized) Row(row []string, ctx tw.Formatting) {
|
||||
c.logger.Debugf("Starting Row render: IsSubRow=%v, Location=%v, Pos=%s, hasFooter=%v",
|
||||
ctx.IsSubRow, ctx.Row.Location, ctx.Row.Position, ctx.HasFooter)
|
||||
|
||||
// Check if there is data to render
|
||||
if len(row) == 0 {
|
||||
c.logger.Debugf("Row: No data to render")
|
||||
return
|
||||
}
|
||||
|
||||
// Render the row line
|
||||
c.renderLine(ctx, row, c.config.Column)
|
||||
c.logger.Debugf("Completed Row render")
|
||||
}
|
||||
|
||||
// Start initializes the rendering process (no-op in this implementation).
|
||||
func (c *Colorized) Start(w io.Writer) error {
|
||||
c.w = w
|
||||
c.logger.Debugf("Colorized.Start() called (no-op).")
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatCell formats a cell's content with color, width, padding, and alignment, handling whitespace trimming and truncation.
|
||||
func (c *Colorized) formatCell(content string, width int, padding tw.Padding, align tw.Align, tint Tint) string {
|
||||
c.logger.Debugf("Formatting cell: content='%s', width=%d, align=%s, paddingL='%s', paddingR='%s', tintFG=%v, tintBG=%v",
|
||||
content, width, align, padding.Left, padding.Right, tint.FG, tint.BG)
|
||||
|
||||
// Return empty string if width is non-positive
|
||||
if width <= 0 {
|
||||
c.logger.Debugf("formatCell: width %d <= 0, returning empty string", width)
|
||||
return tw.Empty
|
||||
}
|
||||
|
||||
// Calculate visual width of content
|
||||
contentVisualWidth := twwidth.Width(content)
|
||||
|
||||
// Set padding characters
|
||||
padLeftCharStr := padding.Left
|
||||
padRightCharStr := padding.Right
|
||||
|
||||
// Determine the character to use for alignment filling.
|
||||
// We default to the padding character defined for that side.
|
||||
// If the padding character is empty (e.g. Overwrite: true), we MUST fallback to Space
|
||||
// for the alignment calculation to prevent the content from shifting incorrectly.
|
||||
alignFillLeft := padLeftCharStr
|
||||
if alignFillLeft == tw.Empty {
|
||||
alignFillLeft = tw.Space
|
||||
}
|
||||
alignFillRight := padRightCharStr
|
||||
if alignFillRight == tw.Empty {
|
||||
alignFillRight = tw.Space
|
||||
}
|
||||
|
||||
// Calculate padding widths
|
||||
definedPadLeftWidth := twwidth.Width(padLeftCharStr)
|
||||
definedPadRightWidth := twwidth.Width(padRightCharStr)
|
||||
|
||||
// Calculate available width for content and alignment
|
||||
availableForContentAndAlign := max(width-definedPadLeftWidth-definedPadRightWidth, 0)
|
||||
|
||||
// Truncate content if it exceeds available width
|
||||
if contentVisualWidth > availableForContentAndAlign {
|
||||
content = twwidth.Truncate(content, availableForContentAndAlign)
|
||||
contentVisualWidth = twwidth.Width(content)
|
||||
c.logger.Debugf("Truncated content to fit %d: '%s' (new width %d)", availableForContentAndAlign, content, contentVisualWidth)
|
||||
}
|
||||
|
||||
// Calculate remaining space for alignment
|
||||
remainingSpaceForAlignment := max(availableForContentAndAlign-contentVisualWidth, 0)
|
||||
|
||||
// Apply alignment padding
|
||||
// Note: We use tw.Pad* helpers here instead of strings.Repeat to handle multi-byte fill chars correctly.
|
||||
leftAlignmentPadSpaces := tw.Empty
|
||||
rightAlignmentPadSpaces := tw.Empty
|
||||
|
||||
switch align {
|
||||
case tw.AlignLeft:
|
||||
rightAlignmentPadSpaces = tw.PadRight(tw.Empty, alignFillRight, remainingSpaceForAlignment)
|
||||
case tw.AlignRight:
|
||||
leftAlignmentPadSpaces = tw.PadLeft(tw.Empty, alignFillLeft, remainingSpaceForAlignment)
|
||||
case tw.AlignCenter:
|
||||
leftSpacesCount := remainingSpaceForAlignment / 2
|
||||
rightSpacesCount := remainingSpaceForAlignment - leftSpacesCount
|
||||
if leftSpacesCount > 0 {
|
||||
leftAlignmentPadSpaces = tw.PadLeft(tw.Empty, alignFillLeft, leftSpacesCount)
|
||||
}
|
||||
if rightSpacesCount > 0 {
|
||||
rightAlignmentPadSpaces = tw.PadRight(tw.Empty, alignFillRight, rightSpacesCount)
|
||||
}
|
||||
default:
|
||||
// Default to left alignment
|
||||
rightAlignmentPadSpaces = tw.PadRight(tw.Empty, alignFillRight, remainingSpaceForAlignment)
|
||||
}
|
||||
|
||||
// Apply colors to content and padding
|
||||
coloredContent := tint.Apply(content)
|
||||
coloredPadLeft := padLeftCharStr
|
||||
coloredPadRight := padRightCharStr
|
||||
coloredAlignPadLeft := leftAlignmentPadSpaces
|
||||
coloredAlignPadRight := rightAlignmentPadSpaces
|
||||
|
||||
if len(tint.BG) > 0 {
|
||||
bgTint := Tint{BG: tint.BG}
|
||||
// Apply foreground color to non-space padding if foreground is defined
|
||||
if len(tint.FG) > 0 && padLeftCharStr != tw.Space {
|
||||
coloredPadLeft = tint.Apply(padLeftCharStr)
|
||||
} else {
|
||||
coloredPadLeft = bgTint.Apply(padLeftCharStr)
|
||||
}
|
||||
if len(tint.FG) > 0 && padRightCharStr != tw.Space {
|
||||
coloredPadRight = tint.Apply(padRightCharStr)
|
||||
} else {
|
||||
coloredPadRight = bgTint.Apply(padRightCharStr)
|
||||
}
|
||||
// Apply background color to alignment padding
|
||||
if leftAlignmentPadSpaces != tw.Empty {
|
||||
coloredAlignPadLeft = bgTint.Apply(leftAlignmentPadSpaces)
|
||||
}
|
||||
if rightAlignmentPadSpaces != tw.Empty {
|
||||
coloredAlignPadRight = bgTint.Apply(rightAlignmentPadSpaces)
|
||||
}
|
||||
} else if len(tint.FG) > 0 {
|
||||
// Apply foreground color to non-space padding
|
||||
if padLeftCharStr != tw.Space {
|
||||
coloredPadLeft = tint.Apply(padLeftCharStr)
|
||||
}
|
||||
if padRightCharStr != tw.Space {
|
||||
coloredPadRight = tint.Apply(padRightCharStr)
|
||||
}
|
||||
}
|
||||
|
||||
// Build final cell string
|
||||
var sb strings.Builder
|
||||
sb.WriteString(coloredPadLeft)
|
||||
sb.WriteString(coloredAlignPadLeft)
|
||||
sb.WriteString(coloredContent)
|
||||
sb.WriteString(coloredAlignPadRight)
|
||||
sb.WriteString(coloredPadRight)
|
||||
output := sb.String()
|
||||
|
||||
// Adjust output width if necessary (safety check)
|
||||
currentVisualWidth := twwidth.Width(output)
|
||||
if currentVisualWidth != width {
|
||||
c.logger.Debugf("formatCell MISMATCH: content='%s', target_w=%d. Calculated parts width = %d. String: '%s'",
|
||||
content, width, currentVisualWidth, output)
|
||||
if currentVisualWidth > width {
|
||||
output = twwidth.Truncate(output, width)
|
||||
} else {
|
||||
paddingSpacesStr := strings.Repeat(tw.Space, width-currentVisualWidth)
|
||||
if len(tint.BG) > 0 {
|
||||
output += Tint{BG: tint.BG}.Apply(paddingSpacesStr)
|
||||
} else {
|
||||
output += paddingSpacesStr
|
||||
}
|
||||
}
|
||||
c.logger.Debugf("formatCell Post-Correction: Target %d, New Visual width %d. Output: '%s'", width, twwidth.Width(output), output)
|
||||
}
|
||||
|
||||
c.logger.Debugf("Formatted cell final result: '%s' (target width %d, display width %d)", output, width, twwidth.Width(output))
|
||||
return output
|
||||
}
|
||||
|
||||
// renderLine renders a single line (header, row, or footer) with colors, handling merges and separators.
|
||||
func (c *Colorized) renderLine(ctx tw.Formatting, line []string, tint Tint) {
|
||||
// Determine number of columns
|
||||
numCols := 0
|
||||
if len(ctx.Row.Current) > 0 {
|
||||
maxKey := -1
|
||||
for k := range ctx.Row.Current {
|
||||
if k > maxKey {
|
||||
maxKey = k
|
||||
}
|
||||
}
|
||||
numCols = maxKey + 1
|
||||
} else {
|
||||
maxKey := -1
|
||||
for k := range ctx.Row.Widths {
|
||||
if k > maxKey {
|
||||
maxKey = k
|
||||
}
|
||||
}
|
||||
numCols = maxKey + 1
|
||||
}
|
||||
|
||||
var output strings.Builder
|
||||
|
||||
// Add left border if enabled
|
||||
prefix := tw.Empty
|
||||
if c.config.Borders.Left.Enabled() {
|
||||
prefix = c.config.Border.Apply(c.config.Symbols.Column())
|
||||
}
|
||||
output.WriteString(prefix)
|
||||
|
||||
// Set up separator
|
||||
separatorDisplayWidth := 0
|
||||
separatorString := tw.Empty
|
||||
if c.config.Settings.Separators.BetweenColumns.Enabled() {
|
||||
separatorString = c.config.Separator.Apply(c.config.Symbols.Column())
|
||||
separatorDisplayWidth = twwidth.Width(c.config.Symbols.Column())
|
||||
}
|
||||
|
||||
// Process each column
|
||||
for i := 0; i < numCols; {
|
||||
// Determine if a separator is needed
|
||||
shouldAddSeparator := false
|
||||
if i > 0 && c.config.Settings.Separators.BetweenColumns.Enabled() {
|
||||
cellCtx, ok := ctx.Row.Current[i]
|
||||
if !ok || (!cellCtx.Merge.Horizontal.Present || cellCtx.Merge.Horizontal.Start) {
|
||||
shouldAddSeparator = true
|
||||
}
|
||||
}
|
||||
if shouldAddSeparator {
|
||||
output.WriteString(separatorString)
|
||||
c.logger.Debugf("renderLine: Added separator '%s' before col %d", separatorString, i)
|
||||
} else if i > 0 {
|
||||
c.logger.Debugf("renderLine: Skipped separator before col %d due to HMerge continuation", i)
|
||||
}
|
||||
|
||||
// Get cell context, use default if not present
|
||||
cellCtx, ok := ctx.Row.Current[i]
|
||||
if !ok {
|
||||
cellCtx = tw.CellContext{
|
||||
Data: tw.Empty,
|
||||
Align: c.defaultAlign[ctx.Row.Position],
|
||||
Padding: tw.Padding{Left: tw.Space, Right: tw.Space},
|
||||
Width: ctx.Row.Widths.Get(i),
|
||||
Merge: tw.MergeState{},
|
||||
}
|
||||
}
|
||||
|
||||
// Handle merged cells
|
||||
visualWidth := 0
|
||||
span := 1
|
||||
isHMergeStart := ok && cellCtx.Merge.Horizontal.Present && cellCtx.Merge.Horizontal.Start
|
||||
|
||||
if isHMergeStart {
|
||||
span = cellCtx.Merge.Horizontal.Span
|
||||
if ctx.Row.Position == tw.Row {
|
||||
// Calculate dynamic width for row merges
|
||||
dynamicTotalWidth := 0
|
||||
for k := 0; k < span && i+k < numCols; k++ {
|
||||
colToSum := i + k
|
||||
normWidth := max(ctx.NormalizedWidths.Get(colToSum), 0)
|
||||
dynamicTotalWidth += normWidth
|
||||
if k > 0 && separatorDisplayWidth > 0 {
|
||||
dynamicTotalWidth += separatorDisplayWidth
|
||||
}
|
||||
}
|
||||
visualWidth = dynamicTotalWidth
|
||||
c.logger.Debugf("renderLine: Row HMerge col %d, span %d, dynamic visualWidth %d", i, span, visualWidth)
|
||||
} else {
|
||||
visualWidth = ctx.Row.Widths.Get(i)
|
||||
c.logger.Debugf("renderLine: H/F HMerge col %d, span %d, pre-adjusted visualWidth %d", i, span, visualWidth)
|
||||
}
|
||||
} else {
|
||||
visualWidth = ctx.Row.Widths.Get(i)
|
||||
c.logger.Debugf("renderLine: Regular col %d, visualWidth %d", i, visualWidth)
|
||||
}
|
||||
if visualWidth < 0 {
|
||||
visualWidth = 0
|
||||
}
|
||||
|
||||
// Skip processing for non-start merged cells
|
||||
if ok && cellCtx.Merge.Horizontal.Present && !cellCtx.Merge.Horizontal.Start {
|
||||
c.logger.Debugf("renderLine: Skipping col %d processing (part of HMerge)", i)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle empty cell context with non-zero width
|
||||
if !ok && visualWidth > 0 {
|
||||
spaces := strings.Repeat(tw.Space, visualWidth)
|
||||
if len(tint.BG) > 0 {
|
||||
output.WriteString(Tint{BG: tint.BG}.Apply(spaces))
|
||||
} else {
|
||||
output.WriteString(spaces)
|
||||
}
|
||||
c.logger.Debugf("renderLine: No cell context for col %d, writing %d spaces", i, visualWidth)
|
||||
i += span
|
||||
continue
|
||||
}
|
||||
|
||||
// Set cell alignment
|
||||
padding := cellCtx.Padding
|
||||
align := cellCtx.Align
|
||||
if align == tw.AlignNone {
|
||||
align = c.defaultAlign[ctx.Row.Position]
|
||||
c.logger.Debugf("renderLine: col %d using default renderer align '%s' for position %s because cellCtx.Align was AlignNone", i, align, ctx.Row.Position)
|
||||
}
|
||||
|
||||
// Detect and handle TOTAL pattern
|
||||
isTotalPattern := false
|
||||
if i == 0 && isHMergeStart && cellCtx.Merge.Horizontal.Span >= 3 && strings.TrimSpace(cellCtx.Data) == "TOTAL" {
|
||||
isTotalPattern = true
|
||||
c.logger.Debugf("renderLine: Detected 'TOTAL' HMerge pattern at col 0")
|
||||
}
|
||||
// Override alignment for footer merges or TOTAL pattern
|
||||
if (ctx.Row.Position == tw.Footer && isHMergeStart) || isTotalPattern {
|
||||
if align == tw.AlignNone {
|
||||
c.logger.Debugf("renderLine: Applying AlignRight override for Footer HMerge/TOTAL pattern at col %d. Original/default align was: %s", i, align)
|
||||
align = tw.AlignRight
|
||||
}
|
||||
}
|
||||
|
||||
// Handle vertical/hierarchical merges
|
||||
content := cellCtx.Data
|
||||
if (cellCtx.Merge.Vertical.Present && !cellCtx.Merge.Vertical.Start) ||
|
||||
(cellCtx.Merge.Hierarchical.Present && !cellCtx.Merge.Hierarchical.Start) {
|
||||
content = tw.Empty
|
||||
c.logger.Debugf("renderLine: Blanked data for col %d (non-start V/Hierarchical)", i)
|
||||
}
|
||||
|
||||
// Apply per-column tint if available
|
||||
cellTint := tint
|
||||
if i < len(tint.Columns) {
|
||||
columnTint := tint.Columns[i]
|
||||
if len(columnTint.FG) > 0 || len(columnTint.BG) > 0 {
|
||||
cellTint = columnTint
|
||||
}
|
||||
}
|
||||
|
||||
// Format and render the cell
|
||||
formattedCell := c.formatCell(content, visualWidth, padding, align, cellTint)
|
||||
if len(formattedCell) > 0 {
|
||||
output.WriteString(formattedCell)
|
||||
} else if visualWidth == 0 && isHMergeStart {
|
||||
c.logger.Debugf("renderLine: Rendered HMerge START col %d resulted in 0 visual width, wrote nothing.", i)
|
||||
} else if visualWidth == 0 {
|
||||
c.logger.Debugf("renderLine: Rendered regular col %d resulted in 0 visual width, wrote nothing.", i)
|
||||
}
|
||||
|
||||
// Log rendering details
|
||||
if isHMergeStart {
|
||||
c.logger.Debugf("renderLine: Rendered HMerge START col %d (span %d, visualWidth %d, align %s): '%s'",
|
||||
i, span, visualWidth, align, formattedCell)
|
||||
} else {
|
||||
c.logger.Debugf("renderLine: Rendered regular col %d (visualWidth %d, align %s): '%s'",
|
||||
i, visualWidth, align, formattedCell)
|
||||
}
|
||||
|
||||
i += span
|
||||
}
|
||||
|
||||
// Add right border if enabled
|
||||
suffix := tw.Empty
|
||||
if c.config.Borders.Right.Enabled() {
|
||||
suffix = c.config.Border.Apply(c.config.Symbols.Column())
|
||||
}
|
||||
output.WriteString(suffix)
|
||||
|
||||
// Write the final line
|
||||
output.WriteString(c.newLine)
|
||||
c.w.Write([]byte(output.String()))
|
||||
c.logger.Debugf("renderLine: Final rendered line: %s", strings.TrimSuffix(output.String(), c.newLine))
|
||||
}
|
||||
|
||||
// Rendition updates the parts of ColorizedConfig that correspond to tw.Rendition
|
||||
// by merging the provided newRendition. Color-specific Tints are not modified.
|
||||
func (c *Colorized) Rendition(newRendition tw.Rendition) { // Method name matches interface
|
||||
c.logger.Debug("Colorized.Rendition called. Current B/Sym/Set: B:%+v, Sym:%T, S:%+v. Override: %+v", c.config.Borders, c.config.Symbols, c.config.Settings, newRendition)
|
||||
|
||||
currentRenditionPart := tw.Rendition{
|
||||
Borders: c.config.Borders,
|
||||
Symbols: c.config.Symbols,
|
||||
Settings: c.config.Settings,
|
||||
}
|
||||
|
||||
mergedRenditionPart := mergeRendition(currentRenditionPart, newRendition)
|
||||
|
||||
c.config.Borders = mergedRenditionPart.Borders
|
||||
c.config.Symbols = mergedRenditionPart.Symbols
|
||||
if c.config.Symbols == nil {
|
||||
c.config.Symbols = tw.NewSymbols(tw.StyleLight)
|
||||
}
|
||||
c.config.Settings = mergedRenditionPart.Settings
|
||||
|
||||
c.logger.Debugf("Colorized.Rendition updated. New B/Sym/Set: B:%+v, Sym:%T, S:%+v",
|
||||
c.config.Borders, c.config.Symbols, c.config.Settings)
|
||||
}
|
||||
|
||||
// Ensure Colorized implements tw.Renditioning
|
||||
var _ tw.Renditioning = (*Colorized)(nil)
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
package renderer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/olekukonko/tablewriter/tw"
|
||||
)
|
||||
|
||||
// defaultBlueprint returns a default Rendition for ASCII table rendering with borders and light symbols.
|
||||
func defaultBlueprint() tw.Rendition {
|
||||
return tw.Rendition{
|
||||
Borders: tw.Border{
|
||||
Left: tw.On,
|
||||
Right: tw.On,
|
||||
Top: tw.On,
|
||||
Bottom: tw.On,
|
||||
},
|
||||
Settings: tw.Settings{
|
||||
Separators: tw.Separators{
|
||||
ShowHeader: tw.On,
|
||||
ShowFooter: tw.On,
|
||||
BetweenRows: tw.Off,
|
||||
BetweenColumns: tw.On,
|
||||
},
|
||||
Lines: tw.Lines{
|
||||
ShowTop: tw.On,
|
||||
ShowBottom: tw.On,
|
||||
ShowHeaderLine: tw.On,
|
||||
ShowFooterLine: tw.On,
|
||||
},
|
||||
CompactMode: tw.Off,
|
||||
// Cushion: tw.On,
|
||||
},
|
||||
Symbols: tw.NewSymbols(tw.StyleLight),
|
||||
Streaming: true,
|
||||
}
|
||||
}
|
||||
|
||||
// defaultColorized returns a default ColorizedConfig optimized for dark terminal backgrounds with colored headers, rows, and borders.
|
||||
func defaultColorized() ColorizedConfig {
|
||||
return ColorizedConfig{
|
||||
Borders: tw.Border{Left: tw.On, Right: tw.On, Top: tw.On, Bottom: tw.On},
|
||||
Settings: tw.Settings{
|
||||
Separators: tw.Separators{
|
||||
ShowHeader: tw.On,
|
||||
ShowFooter: tw.On,
|
||||
BetweenRows: tw.Off,
|
||||
BetweenColumns: tw.On,
|
||||
},
|
||||
Lines: tw.Lines{
|
||||
ShowTop: tw.On,
|
||||
ShowBottom: tw.On,
|
||||
ShowHeaderLine: tw.On,
|
||||
ShowFooterLine: tw.On,
|
||||
},
|
||||
|
||||
CompactMode: tw.Off,
|
||||
},
|
||||
Header: Tint{
|
||||
FG: Colors{color.FgWhite, color.Bold},
|
||||
BG: Colors{color.BgBlack},
|
||||
},
|
||||
Column: Tint{
|
||||
FG: Colors{color.FgCyan},
|
||||
BG: Colors{color.BgBlack},
|
||||
},
|
||||
Footer: Tint{
|
||||
FG: Colors{color.FgYellow},
|
||||
BG: Colors{color.BgBlack},
|
||||
},
|
||||
Border: Tint{
|
||||
FG: Colors{color.FgWhite},
|
||||
BG: Colors{color.BgBlack},
|
||||
},
|
||||
Separator: Tint{
|
||||
FG: Colors{color.FgWhite},
|
||||
BG: Colors{color.BgBlack},
|
||||
},
|
||||
Symbols: tw.NewSymbols(tw.StyleLight),
|
||||
}
|
||||
}
|
||||
|
||||
// defaultOceanRendererConfig returns a base tw.Rendition for the Ocean renderer.
|
||||
func defaultOceanRendererConfig() tw.Rendition {
|
||||
return tw.Rendition{
|
||||
Borders: tw.Border{
|
||||
Left: tw.On, Right: tw.On, Top: tw.On, Bottom: tw.On,
|
||||
},
|
||||
Settings: tw.Settings{
|
||||
Separators: tw.Separators{
|
||||
ShowHeader: tw.On,
|
||||
ShowFooter: tw.Off,
|
||||
BetweenRows: tw.Off,
|
||||
BetweenColumns: tw.On,
|
||||
},
|
||||
Lines: tw.Lines{
|
||||
ShowTop: tw.On,
|
||||
ShowBottom: tw.On,
|
||||
ShowHeaderLine: tw.On,
|
||||
ShowFooterLine: tw.Off,
|
||||
},
|
||||
|
||||
CompactMode: tw.Off,
|
||||
},
|
||||
Symbols: tw.NewSymbols(tw.StyleDefault),
|
||||
Streaming: true,
|
||||
}
|
||||
}
|
||||
|
||||
// getHTMLStyle remains the same
|
||||
func getHTMLStyle(align tw.Align) string {
|
||||
styleContent := tw.Empty
|
||||
switch align {
|
||||
case tw.AlignRight:
|
||||
styleContent = "text-align: right;"
|
||||
case tw.AlignCenter:
|
||||
styleContent = "text-align: center;"
|
||||
case tw.AlignLeft:
|
||||
styleContent = "text-align: left;"
|
||||
}
|
||||
if styleContent != tw.Empty {
|
||||
return fmt.Sprintf(` style="%s"`, styleContent)
|
||||
}
|
||||
return tw.Empty
|
||||
}
|
||||
|
||||
// mergeLines combines default and override line settings, preserving defaults for unset (zero) overrides.
|
||||
func mergeLines(defaults, overrides tw.Lines) tw.Lines {
|
||||
if overrides.ShowTop != 0 {
|
||||
defaults.ShowTop = overrides.ShowTop
|
||||
}
|
||||
if overrides.ShowBottom != 0 {
|
||||
defaults.ShowBottom = overrides.ShowBottom
|
||||
}
|
||||
if overrides.ShowHeaderLine != 0 {
|
||||
defaults.ShowHeaderLine = overrides.ShowHeaderLine
|
||||
}
|
||||
if overrides.ShowFooterLine != 0 {
|
||||
defaults.ShowFooterLine = overrides.ShowFooterLine
|
||||
}
|
||||
return defaults
|
||||
}
|
||||
|
||||
// mergeSeparators combines default and override separator settings, preserving defaults for unset (zero) overrides.
|
||||
func mergeSeparators(defaults, overrides tw.Separators) tw.Separators {
|
||||
if overrides.ShowHeader != 0 {
|
||||
defaults.ShowHeader = overrides.ShowHeader
|
||||
}
|
||||
if overrides.ShowFooter != 0 {
|
||||
defaults.ShowFooter = overrides.ShowFooter
|
||||
}
|
||||
if overrides.BetweenRows != 0 {
|
||||
defaults.BetweenRows = overrides.BetweenRows
|
||||
}
|
||||
if overrides.BetweenColumns != 0 {
|
||||
defaults.BetweenColumns = overrides.BetweenColumns
|
||||
}
|
||||
return defaults
|
||||
}
|
||||
|
||||
// mergeSettings combines default and override settings, preserving defaults for unset (zero) overrides.
|
||||
func mergeSettings(defaults, overrides tw.Settings) tw.Settings {
|
||||
if overrides.Separators.ShowHeader != tw.Unknown {
|
||||
defaults.Separators.ShowHeader = overrides.Separators.ShowHeader
|
||||
}
|
||||
if overrides.Separators.ShowFooter != tw.Unknown {
|
||||
defaults.Separators.ShowFooter = overrides.Separators.ShowFooter
|
||||
}
|
||||
if overrides.Separators.BetweenRows != tw.Unknown {
|
||||
defaults.Separators.BetweenRows = overrides.Separators.BetweenRows
|
||||
}
|
||||
if overrides.Separators.BetweenColumns != tw.Unknown {
|
||||
defaults.Separators.BetweenColumns = overrides.Separators.BetweenColumns
|
||||
}
|
||||
if overrides.Lines.ShowTop != tw.Unknown {
|
||||
defaults.Lines.ShowTop = overrides.Lines.ShowTop
|
||||
}
|
||||
if overrides.Lines.ShowBottom != tw.Unknown {
|
||||
defaults.Lines.ShowBottom = overrides.Lines.ShowBottom
|
||||
}
|
||||
if overrides.Lines.ShowHeaderLine != tw.Unknown {
|
||||
defaults.Lines.ShowHeaderLine = overrides.Lines.ShowHeaderLine
|
||||
}
|
||||
if overrides.Lines.ShowFooterLine != tw.Unknown {
|
||||
defaults.Lines.ShowFooterLine = overrides.Lines.ShowFooterLine
|
||||
}
|
||||
|
||||
if overrides.CompactMode != tw.Unknown {
|
||||
defaults.CompactMode = overrides.CompactMode
|
||||
}
|
||||
|
||||
// if overrides.Cushion != tw.Unknown {
|
||||
// defaults.Cushion = overrides.Cushion
|
||||
//}
|
||||
|
||||
return defaults
|
||||
}
|
||||
|
||||
// MergeRendition merges the 'override' rendition into the 'current' rendition.
|
||||
// It only updates fields in 'current' if they are explicitly set (non-zero/non-nil) in 'override'.
|
||||
// This allows for partial updates to a renderer's configuration.
|
||||
func mergeRendition(current, override tw.Rendition) tw.Rendition {
|
||||
// Merge Borders: Only update if override border states are explicitly set (not 0).
|
||||
// A tw.State's zero value is 0, which is distinct from tw.On (1) or tw.Off (-1).
|
||||
// So, if override.Borders.Left is 0, it means "not specified", so we keep current.
|
||||
if override.Borders.Left != 0 {
|
||||
current.Borders.Left = override.Borders.Left
|
||||
}
|
||||
if override.Borders.Right != 0 {
|
||||
current.Borders.Right = override.Borders.Right
|
||||
}
|
||||
if override.Borders.Top != 0 {
|
||||
current.Borders.Top = override.Borders.Top
|
||||
}
|
||||
if override.Borders.Bottom != 0 {
|
||||
current.Borders.Bottom = override.Borders.Bottom
|
||||
}
|
||||
|
||||
// Merge Symbols: Only update if override.Symbols is not nil.
|
||||
if override.Symbols != nil {
|
||||
current.Symbols = override.Symbols
|
||||
}
|
||||
|
||||
// Merge Settings: Use the existing mergeSettings for granular control.
|
||||
// mergeSettings already handles preserving defaults for unset (zero) overrides.
|
||||
current.Settings = mergeSettings(current.Settings, override.Settings)
|
||||
|
||||
// Streaming flag: typically set at renderer creation, but can be overridden if needed.
|
||||
// For now, let's assume it's not commonly changed post-creation by a generic rendition merge.
|
||||
// If override provides a different streaming capability, it might indicate a fundamental
|
||||
// change that a simple merge shouldn't handle without more context.
|
||||
// current.Streaming = override.Streaming // Or keep current.Streaming
|
||||
|
||||
return current
|
||||
}
|
||||
+442
@@ -0,0 +1,442 @@
|
||||
package renderer
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/olekukonko/ll"
|
||||
|
||||
"github.com/olekukonko/tablewriter/tw"
|
||||
)
|
||||
|
||||
// HTMLConfig defines settings for the HTML table renderer.
|
||||
type HTMLConfig struct {
|
||||
EscapeContent bool // Whether to escape cell content
|
||||
AddLinesTag bool // Whether to wrap multiline content in <lines> tags
|
||||
TableClass string // CSS class for <table>
|
||||
HeaderClass string // CSS class for <thead>
|
||||
BodyClass string // CSS class for <tbody>
|
||||
FooterClass string // CSS class for <tfoot>
|
||||
RowClass string // CSS class for <tr> in body
|
||||
HeaderRowClass string // CSS class for <tr> in header
|
||||
FooterRowClass string // CSS class for <tr> in footer
|
||||
}
|
||||
|
||||
// HTML renders tables in HTML format with customizable classes and content handling.
|
||||
type HTML struct {
|
||||
config HTMLConfig // Renderer configuration
|
||||
w io.Writer // Output w
|
||||
trace []string // Debug trace messages
|
||||
debug bool // Enables debug logging
|
||||
tableStarted bool // Tracks if <table> tag is open
|
||||
tbodyStarted bool // Tracks if <tbody> tag is open
|
||||
tfootStarted bool // Tracks if <tfoot> tag is open
|
||||
vMergeTrack map[int]int // Tracks vertical merge spans by column index
|
||||
logger *ll.Logger
|
||||
}
|
||||
|
||||
// NewHTML initializes an HTML renderer with the given w, debug setting, and optional configuration.
|
||||
// It panics if the w is nil and applies defaults for unset config fields.
|
||||
// Update: see https://github.com/olekukonko/tablewriter/issues/258
|
||||
func NewHTML(configs ...HTMLConfig) *HTML {
|
||||
cfg := HTMLConfig{
|
||||
EscapeContent: true,
|
||||
AddLinesTag: false,
|
||||
}
|
||||
if len(configs) > 0 {
|
||||
userCfg := configs[0]
|
||||
cfg.EscapeContent = userCfg.EscapeContent
|
||||
cfg.AddLinesTag = userCfg.AddLinesTag
|
||||
cfg.TableClass = userCfg.TableClass
|
||||
cfg.HeaderClass = userCfg.HeaderClass
|
||||
cfg.BodyClass = userCfg.BodyClass
|
||||
cfg.FooterClass = userCfg.FooterClass
|
||||
cfg.RowClass = userCfg.RowClass
|
||||
cfg.HeaderRowClass = userCfg.HeaderRowClass
|
||||
cfg.FooterRowClass = userCfg.FooterRowClass
|
||||
}
|
||||
return &HTML{
|
||||
config: cfg,
|
||||
vMergeTrack: make(map[int]int),
|
||||
tableStarted: false,
|
||||
tbodyStarted: false,
|
||||
tfootStarted: false,
|
||||
logger: ll.New("html").Disable(),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HTML) Logger(logger *ll.Logger) {
|
||||
h.logger = logger
|
||||
}
|
||||
|
||||
// Config returns a Rendition representation of the current configuration.
|
||||
func (h *HTML) Config() tw.Rendition {
|
||||
return tw.Rendition{
|
||||
Borders: tw.BorderNone,
|
||||
Symbols: tw.NewSymbols(tw.StyleNone),
|
||||
Settings: tw.Settings{},
|
||||
Streaming: false,
|
||||
}
|
||||
}
|
||||
|
||||
// debugLog appends a formatted message to the debug trace if debugging is enabled.
|
||||
// func (h *HTML) debugLog(format string, a ...interface{}) {
|
||||
// if h.debug {
|
||||
// msg := fmt.Sprintf(format, a...)
|
||||
// h.trace = append(h.trace, fmt.Sprintf("[HTML] %s", msg))
|
||||
// }
|
||||
//}
|
||||
|
||||
// Debug returns the accumulated debug trace messages.
|
||||
func (h *HTML) Debug() []string {
|
||||
return h.trace
|
||||
}
|
||||
|
||||
// Start begins the HTML table rendering by opening the <table> tag.
|
||||
func (h *HTML) Start(w io.Writer) error {
|
||||
h.w = w
|
||||
h.Reset()
|
||||
h.logger.Debug("HTML.Start() called.")
|
||||
|
||||
classAttr := tw.Empty
|
||||
if h.config.TableClass != tw.Empty {
|
||||
classAttr = fmt.Sprintf(` class="%s"`, h.config.TableClass)
|
||||
}
|
||||
h.logger.Debugf("Writing opening <table%s> tag", classAttr)
|
||||
_, err := fmt.Fprintf(h.w, "<table%s>\n", classAttr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.tableStarted = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// closePreviousSection closes any open <tbody> or <tfoot> sections.
|
||||
func (h *HTML) closePreviousSection() {
|
||||
if h.tbodyStarted {
|
||||
h.logger.Debug("Closing <tbody> tag")
|
||||
fmt.Fprintln(h.w, "</tbody>")
|
||||
h.tbodyStarted = false
|
||||
}
|
||||
if h.tfootStarted {
|
||||
h.logger.Debug("Closing <tfoot> tag")
|
||||
fmt.Fprintln(h.w, "</tfoot>")
|
||||
h.tfootStarted = false
|
||||
}
|
||||
}
|
||||
|
||||
// Header renders the <thead> section with header rows, supporting horizontal merges.
|
||||
func (h *HTML) Header(headers [][]string, ctx tw.Formatting) {
|
||||
if !h.tableStarted {
|
||||
h.logger.Debug("WARN: Header called before Start")
|
||||
return
|
||||
}
|
||||
if len(headers) == 0 || len(headers[0]) == 0 {
|
||||
h.logger.Debug("Header: No headers")
|
||||
return
|
||||
}
|
||||
|
||||
h.closePreviousSection()
|
||||
classAttr := tw.Empty
|
||||
if h.config.HeaderClass != tw.Empty {
|
||||
classAttr = fmt.Sprintf(` class="%s"`, h.config.HeaderClass)
|
||||
}
|
||||
fmt.Fprintf(h.w, "<thead%s>\n", classAttr)
|
||||
|
||||
headerRow := headers[0]
|
||||
numCols := 0
|
||||
if len(ctx.Row.Current) > 0 {
|
||||
maxKey := -1
|
||||
for k := range ctx.Row.Current {
|
||||
if k > maxKey {
|
||||
maxKey = k
|
||||
}
|
||||
}
|
||||
numCols = maxKey + 1
|
||||
} else if len(headerRow) > 0 {
|
||||
numCols = len(headerRow)
|
||||
}
|
||||
|
||||
indent := " "
|
||||
rowClassAttr := tw.Empty
|
||||
if h.config.HeaderRowClass != tw.Empty {
|
||||
rowClassAttr = fmt.Sprintf(` class="%s"`, h.config.HeaderRowClass)
|
||||
}
|
||||
fmt.Fprintf(h.w, "%s<tr%s>", indent, rowClassAttr)
|
||||
|
||||
renderedCols := 0
|
||||
for colIdx := 0; renderedCols < numCols && colIdx < numCols; {
|
||||
// Skip columns consumed by vertical merges
|
||||
if remainingSpan, merging := h.vMergeTrack[colIdx]; merging && remainingSpan > 1 {
|
||||
h.logger.Debugf("Header: Skipping col %d due to vmerge", colIdx)
|
||||
h.vMergeTrack[colIdx]--
|
||||
if h.vMergeTrack[colIdx] <= 1 {
|
||||
delete(h.vMergeTrack, colIdx)
|
||||
}
|
||||
colIdx++
|
||||
continue
|
||||
}
|
||||
|
||||
// Render cell
|
||||
cellCtx, ok := ctx.Row.Current[colIdx]
|
||||
if !ok {
|
||||
cellCtx = tw.CellContext{Align: tw.AlignCenter}
|
||||
}
|
||||
originalContent := tw.Empty
|
||||
if colIdx < len(headerRow) {
|
||||
originalContent = headerRow[colIdx]
|
||||
}
|
||||
|
||||
tag, attributes, processedContent := h.renderRowCell(originalContent, cellCtx, true, colIdx)
|
||||
fmt.Fprintf(h.w, "<%s%s>%s</%s>", tag, attributes, processedContent, tag)
|
||||
renderedCols++
|
||||
|
||||
// Handle horizontal merges
|
||||
hSpan := 1
|
||||
if cellCtx.Merge.Horizontal.Present && cellCtx.Merge.Horizontal.Start {
|
||||
hSpan = cellCtx.Merge.Horizontal.Span
|
||||
renderedCols += (hSpan - 1)
|
||||
}
|
||||
colIdx += hSpan
|
||||
}
|
||||
fmt.Fprintf(h.w, "</tr>\n")
|
||||
fmt.Fprintln(h.w, "</thead>")
|
||||
}
|
||||
|
||||
// Row renders a <tr> element within <tbody>, supporting horizontal and vertical merges.
|
||||
func (h *HTML) Row(row []string, ctx tw.Formatting) {
|
||||
if !h.tableStarted {
|
||||
h.logger.Debug("WARN: Row called before Start")
|
||||
return
|
||||
}
|
||||
|
||||
if !h.tbodyStarted {
|
||||
h.closePreviousSection()
|
||||
classAttr := tw.Empty
|
||||
if h.config.BodyClass != tw.Empty {
|
||||
classAttr = fmt.Sprintf(` class="%s"`, h.config.BodyClass)
|
||||
}
|
||||
h.logger.Debugf("Writing opening <tbody%s> tag", classAttr)
|
||||
fmt.Fprintf(h.w, "<tbody%s>\n", classAttr)
|
||||
h.tbodyStarted = true
|
||||
}
|
||||
|
||||
h.logger.Debugf("Rendering row data: %v", row)
|
||||
numCols := 0
|
||||
if len(ctx.Row.Current) > 0 {
|
||||
maxKey := -1
|
||||
for k := range ctx.Row.Current {
|
||||
if k > maxKey {
|
||||
maxKey = k
|
||||
}
|
||||
}
|
||||
numCols = maxKey + 1
|
||||
} else if len(row) > 0 {
|
||||
numCols = len(row)
|
||||
}
|
||||
|
||||
indent := " "
|
||||
rowClassAttr := tw.Empty
|
||||
if h.config.RowClass != tw.Empty {
|
||||
rowClassAttr = fmt.Sprintf(` class="%s"`, h.config.RowClass)
|
||||
}
|
||||
fmt.Fprintf(h.w, "%s<tr%s>", indent, rowClassAttr)
|
||||
|
||||
renderedCols := 0
|
||||
for colIdx := 0; renderedCols < numCols && colIdx < numCols; {
|
||||
// Skip columns consumed by vertical merges
|
||||
if remainingSpan, merging := h.vMergeTrack[colIdx]; merging && remainingSpan > 1 {
|
||||
h.logger.Debugf("Row: Skipping render for col %d due to vertical merge (remaining %d)", colIdx, remainingSpan-1)
|
||||
h.vMergeTrack[colIdx]--
|
||||
if h.vMergeTrack[colIdx] <= 1 {
|
||||
delete(h.vMergeTrack, colIdx)
|
||||
}
|
||||
colIdx++
|
||||
continue
|
||||
}
|
||||
|
||||
// Render cell
|
||||
cellCtx, ok := ctx.Row.Current[colIdx]
|
||||
if !ok {
|
||||
cellCtx = tw.CellContext{Align: tw.AlignLeft}
|
||||
}
|
||||
originalContent := tw.Empty
|
||||
if colIdx < len(row) {
|
||||
originalContent = row[colIdx]
|
||||
}
|
||||
|
||||
tag, attributes, processedContent := h.renderRowCell(originalContent, cellCtx, false, colIdx)
|
||||
fmt.Fprintf(h.w, "<%s%s>%s</%s>", tag, attributes, processedContent, tag)
|
||||
renderedCols++
|
||||
|
||||
// Handle horizontal merges
|
||||
hSpan := 1
|
||||
if cellCtx.Merge.Horizontal.Present && cellCtx.Merge.Horizontal.Start {
|
||||
hSpan = cellCtx.Merge.Horizontal.Span
|
||||
renderedCols += (hSpan - 1)
|
||||
}
|
||||
colIdx += hSpan
|
||||
}
|
||||
fmt.Fprintf(h.w, "</tr>\n")
|
||||
}
|
||||
|
||||
// Footer renders the <tfoot> section with footer rows, supporting horizontal merges.
|
||||
func (h *HTML) Footer(footers [][]string, ctx tw.Formatting) {
|
||||
if !h.tableStarted {
|
||||
h.logger.Debug("WARN: Footer called before Start")
|
||||
return
|
||||
}
|
||||
if len(footers) == 0 || len(footers[0]) == 0 {
|
||||
h.logger.Debug("Footer: No footers")
|
||||
return
|
||||
}
|
||||
|
||||
h.closePreviousSection()
|
||||
classAttr := tw.Empty
|
||||
if h.config.FooterClass != tw.Empty {
|
||||
classAttr = fmt.Sprintf(` class="%s"`, h.config.FooterClass)
|
||||
}
|
||||
fmt.Fprintf(h.w, "<tfoot%s>\n", classAttr)
|
||||
h.tfootStarted = true
|
||||
|
||||
footerRow := footers[0]
|
||||
numCols := 0
|
||||
if len(ctx.Row.Current) > 0 {
|
||||
maxKey := -1
|
||||
for k := range ctx.Row.Current {
|
||||
if k > maxKey {
|
||||
maxKey = k
|
||||
}
|
||||
}
|
||||
numCols = maxKey + 1
|
||||
} else if len(footerRow) > 0 {
|
||||
numCols = len(footerRow)
|
||||
}
|
||||
|
||||
indent := " "
|
||||
rowClassAttr := tw.Empty
|
||||
if h.config.FooterRowClass != tw.Empty {
|
||||
rowClassAttr = fmt.Sprintf(` class="%s"`, h.config.FooterRowClass)
|
||||
}
|
||||
fmt.Fprintf(h.w, "%s<tr%s>", indent, rowClassAttr)
|
||||
|
||||
renderedCols := 0
|
||||
for colIdx := 0; renderedCols < numCols && colIdx < numCols; {
|
||||
cellCtx, ok := ctx.Row.Current[colIdx]
|
||||
if !ok {
|
||||
cellCtx = tw.CellContext{Align: tw.AlignRight}
|
||||
}
|
||||
originalContent := tw.Empty
|
||||
if colIdx < len(footerRow) {
|
||||
originalContent = footerRow[colIdx]
|
||||
}
|
||||
|
||||
tag, attributes, processedContent := h.renderRowCell(originalContent, cellCtx, false, colIdx)
|
||||
fmt.Fprintf(h.w, "<%s%s>%s</%s>", tag, attributes, processedContent, tag)
|
||||
renderedCols++
|
||||
|
||||
hSpan := 1
|
||||
if cellCtx.Merge.Horizontal.Present && cellCtx.Merge.Horizontal.Start {
|
||||
hSpan = cellCtx.Merge.Horizontal.Span
|
||||
renderedCols += (hSpan - 1)
|
||||
}
|
||||
colIdx += hSpan
|
||||
}
|
||||
fmt.Fprintf(h.w, "</tr>\n")
|
||||
fmt.Fprintln(h.w, "</tfoot>")
|
||||
h.tfootStarted = false
|
||||
}
|
||||
|
||||
// renderRowCell generates HTML for a single cell, handling content escaping, merges, and alignment.
|
||||
func (h *HTML) renderRowCell(originalContent string, cellCtx tw.CellContext, isHeader bool, colIdx int) (tag, attributes, processedContent string) {
|
||||
tag = "td"
|
||||
if isHeader {
|
||||
tag = "th"
|
||||
}
|
||||
|
||||
// Process content
|
||||
processedContent = originalContent
|
||||
containsNewline := strings.Contains(originalContent, "\n")
|
||||
|
||||
if h.config.EscapeContent {
|
||||
if containsNewline {
|
||||
const newlinePlaceholder = "[[--HTML_RENDERER_BR_PLACEHOLDER--]]"
|
||||
tempContent := strings.ReplaceAll(originalContent, "\n", newlinePlaceholder)
|
||||
escapedContent := html.EscapeString(tempContent)
|
||||
processedContent = strings.ReplaceAll(escapedContent, newlinePlaceholder, "<br>")
|
||||
} else {
|
||||
processedContent = html.EscapeString(originalContent)
|
||||
}
|
||||
} else if containsNewline {
|
||||
processedContent = strings.ReplaceAll(originalContent, "\n", "<br>")
|
||||
}
|
||||
|
||||
if containsNewline && h.config.AddLinesTag {
|
||||
processedContent = "<lines>" + processedContent + "</lines>"
|
||||
}
|
||||
|
||||
// Build attributes
|
||||
var attrBuilder strings.Builder
|
||||
merge := cellCtx.Merge
|
||||
|
||||
if merge.Horizontal.Present && merge.Horizontal.Start && merge.Horizontal.Span > 1 {
|
||||
fmt.Fprintf(&attrBuilder, ` colspan="%d"`, merge.Horizontal.Span)
|
||||
}
|
||||
|
||||
vSpan := 0
|
||||
if !isHeader {
|
||||
if merge.Vertical.Present && merge.Vertical.Start {
|
||||
vSpan = merge.Vertical.Span
|
||||
} else if merge.Hierarchical.Present && merge.Hierarchical.Start {
|
||||
vSpan = merge.Hierarchical.Span
|
||||
}
|
||||
if vSpan > 1 {
|
||||
fmt.Fprintf(&attrBuilder, ` rowspan="%d"`, vSpan)
|
||||
h.vMergeTrack[colIdx] = vSpan
|
||||
h.logger.Debugf("renderRowCell: Tracking rowspan=%d for col %d", vSpan, colIdx)
|
||||
}
|
||||
}
|
||||
|
||||
if style := getHTMLStyle(cellCtx.Align); style != tw.Empty {
|
||||
attrBuilder.WriteString(style)
|
||||
}
|
||||
attributes = attrBuilder.String()
|
||||
return
|
||||
}
|
||||
|
||||
// Line is a no-op for HTML rendering, as structural lines are handled by tags.
|
||||
func (h *HTML) Line(ctx tw.Formatting) {}
|
||||
|
||||
// Reset clears the renderer's internal state, including debug traces and merge tracking.
|
||||
func (h *HTML) Reset() {
|
||||
h.logger.Debug("HTML.Reset() called.")
|
||||
h.tableStarted = false
|
||||
h.tbodyStarted = false
|
||||
h.tfootStarted = false
|
||||
h.vMergeTrack = make(map[int]int)
|
||||
h.trace = nil
|
||||
}
|
||||
|
||||
// Close ensures all open HTML tags (<table>, <tbody>, <tfoot>) are properly closed.
|
||||
func (h *HTML) Close() error {
|
||||
if h.w == nil {
|
||||
return errors.New("HTML Renderer Close called on nil internal w")
|
||||
}
|
||||
|
||||
if h.tableStarted {
|
||||
h.logger.Debug("HTML.Close() called.")
|
||||
h.closePreviousSection()
|
||||
h.logger.Debug("Closing <table> tag.")
|
||||
_, err := fmt.Fprintln(h.w, "</table>")
|
||||
h.tableStarted = false
|
||||
h.tbodyStarted = false
|
||||
h.tfootStarted = false
|
||||
h.vMergeTrack = make(map[int]int)
|
||||
return err
|
||||
}
|
||||
h.logger.Debug("HTML.Close() called, but table was not started (no-op).")
|
||||
return nil
|
||||
}
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
package renderer
|
||||
|
||||
import (
|
||||
"github.com/olekukonko/ll"
|
||||
"github.com/olekukonko/tablewriter/tw"
|
||||
)
|
||||
|
||||
// Junction handles rendering of table junction points (corners, intersections) with color support.
|
||||
type Junction struct {
|
||||
sym tw.Symbols // Symbols used for rendering junctions and lines
|
||||
ctx tw.Formatting // Current table formatting context
|
||||
colIdx int // Index of the column being processed
|
||||
debugging bool // Enables debug logging
|
||||
borderTint Tint // Colors for border symbols
|
||||
separatorTint Tint // Colors for separator symbols
|
||||
logger *ll.Logger
|
||||
}
|
||||
|
||||
type JunctionContext struct {
|
||||
Symbols tw.Symbols
|
||||
Ctx tw.Formatting
|
||||
ColIdx int
|
||||
Logger *ll.Logger
|
||||
BorderTint Tint
|
||||
SeparatorTint Tint
|
||||
}
|
||||
|
||||
// NewJunction initializes a Junction with the given symbols, context, and tints.
|
||||
// If debug is nil, a no-op debug function is used.
|
||||
func NewJunction(ctx JunctionContext) *Junction {
|
||||
return &Junction{
|
||||
sym: ctx.Symbols,
|
||||
ctx: ctx.Ctx,
|
||||
colIdx: ctx.ColIdx,
|
||||
logger: ctx.Logger.Namespace("junction"),
|
||||
borderTint: ctx.BorderTint,
|
||||
separatorTint: ctx.SeparatorTint,
|
||||
}
|
||||
}
|
||||
|
||||
// getMergeState retrieves the merge state for a specific column in a row, returning an empty state if not found.
|
||||
func (jr *Junction) getMergeState(row map[int]tw.CellContext, colIdx int) tw.MergeState {
|
||||
if row == nil || colIdx < 0 {
|
||||
return tw.MergeState{}
|
||||
}
|
||||
return row[colIdx].Merge
|
||||
}
|
||||
|
||||
// GetSegment determines whether to render a colored horizontal line or an empty space based on merge states.
|
||||
func (jr *Junction) GetSegment() string {
|
||||
currentMerge := jr.getMergeState(jr.ctx.Row.Current, jr.colIdx)
|
||||
nextMerge := jr.getMergeState(jr.ctx.Row.Next, jr.colIdx)
|
||||
|
||||
vPassThruStrict := (currentMerge.Vertical.Present && nextMerge.Vertical.Present && !currentMerge.Vertical.End && !nextMerge.Vertical.Start) ||
|
||||
(currentMerge.Hierarchical.Present && nextMerge.Hierarchical.Present && !currentMerge.Hierarchical.End && !nextMerge.Hierarchical.Start)
|
||||
|
||||
if vPassThruStrict {
|
||||
jr.logger.Debugf("GetSegment col %d: VPassThruStrict=%v -> Empty segment", jr.colIdx, vPassThruStrict)
|
||||
return tw.Empty
|
||||
}
|
||||
symbol := jr.sym.Row()
|
||||
coloredSymbol := jr.borderTint.Apply(symbol)
|
||||
jr.logger.Debugf("GetSegment col %d: VPassThruStrict=%v -> Colored row symbol '%s'", jr.colIdx, vPassThruStrict, coloredSymbol)
|
||||
return coloredSymbol
|
||||
}
|
||||
|
||||
// RenderLeft selects and colors the leftmost junction symbol for the current row line based on position and merges.
|
||||
func (jr *Junction) RenderLeft() string {
|
||||
mergeAbove := jr.getMergeState(jr.ctx.Row.Current, 0)
|
||||
mergeBelow := jr.getMergeState(jr.ctx.Row.Next, 0)
|
||||
|
||||
jr.logger.Debugf("RenderLeft: Level=%v, Location=%v, Previous=%v", jr.ctx.Level, jr.ctx.Row.Location, jr.ctx.Row.Previous)
|
||||
|
||||
isTopBorder := (jr.ctx.Level == tw.LevelHeader && jr.ctx.Row.Location == tw.LocationFirst) ||
|
||||
(jr.ctx.Level == tw.LevelBody && jr.ctx.Row.Location == tw.LocationFirst && jr.ctx.Row.Previous == nil)
|
||||
if isTopBorder {
|
||||
symbol := jr.sym.TopLeft()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
|
||||
isBottom := jr.ctx.Level == tw.LevelBody && jr.ctx.Row.Location == tw.LocationEnd && !jr.ctx.HasFooter
|
||||
isFooter := jr.ctx.Level == tw.LevelFooter && jr.ctx.Row.Location == tw.LocationEnd
|
||||
if isBottom || isFooter {
|
||||
symbol := jr.sym.BottomLeft()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
|
||||
isVPassThruStrict := (mergeAbove.Vertical.Present && mergeBelow.Vertical.Present && !mergeAbove.Vertical.End && !mergeBelow.Vertical.Start) ||
|
||||
(mergeAbove.Hierarchical.Present && mergeBelow.Hierarchical.Present && !mergeAbove.Hierarchical.End && !mergeBelow.Hierarchical.Start)
|
||||
if isVPassThruStrict {
|
||||
symbol := jr.sym.Column()
|
||||
return jr.separatorTint.Apply(symbol)
|
||||
}
|
||||
|
||||
symbol := jr.sym.MidLeft()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
|
||||
// RenderRight selects and colors the rightmost junction symbol for the row line based on position, merges, and last column index.
|
||||
func (jr *Junction) RenderRight(lastColIdx int) string {
|
||||
jr.logger.Debugf("RenderRight: lastColIdx=%d, Level=%v, Location=%v, Previous=%v", lastColIdx, jr.ctx.Level, jr.ctx.Row.Location, jr.ctx.Row.Previous)
|
||||
|
||||
if lastColIdx < 0 {
|
||||
switch jr.ctx.Level {
|
||||
case tw.LevelHeader:
|
||||
symbol := jr.sym.TopRight()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
case tw.LevelFooter:
|
||||
symbol := jr.sym.BottomRight()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
default:
|
||||
if jr.ctx.Row.Location == tw.LocationFirst {
|
||||
symbol := jr.sym.TopRight()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
if jr.ctx.Row.Location == tw.LocationEnd {
|
||||
symbol := jr.sym.BottomRight()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
symbol := jr.sym.MidRight()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
}
|
||||
|
||||
mergeAbove := jr.getMergeState(jr.ctx.Row.Current, lastColIdx)
|
||||
mergeBelow := jr.getMergeState(jr.ctx.Row.Next, lastColIdx)
|
||||
|
||||
isTopBorder := (jr.ctx.Level == tw.LevelHeader && jr.ctx.Row.Location == tw.LocationFirst) ||
|
||||
(jr.ctx.Level == tw.LevelBody && jr.ctx.Row.Location == tw.LocationFirst && jr.ctx.Row.Previous == nil)
|
||||
if isTopBorder {
|
||||
symbol := jr.sym.TopRight()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
|
||||
isBottom := jr.ctx.Level == tw.LevelBody && jr.ctx.Row.Location == tw.LocationEnd && !jr.ctx.HasFooter
|
||||
isFooter := jr.ctx.Level == tw.LevelFooter && jr.ctx.Row.Location == tw.LocationEnd
|
||||
if isBottom || isFooter {
|
||||
symbol := jr.sym.BottomRight()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
|
||||
isVPassThruStrict := (mergeAbove.Vertical.Present && mergeBelow.Vertical.Present && !mergeAbove.Vertical.End && !mergeBelow.Vertical.Start) ||
|
||||
(mergeAbove.Hierarchical.Present && mergeBelow.Hierarchical.Present && !mergeAbove.Hierarchical.End && !mergeBelow.Hierarchical.Start)
|
||||
if isVPassThruStrict {
|
||||
symbol := jr.sym.Column()
|
||||
return jr.separatorTint.Apply(symbol)
|
||||
}
|
||||
|
||||
symbol := jr.sym.MidRight()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
|
||||
// RenderJunction selects and colors the junction symbol between two adjacent columns based on merge states and table position.
|
||||
func (jr *Junction) RenderJunction(leftColIdx, rightColIdx int) string {
|
||||
mergeCurrentL := jr.getMergeState(jr.ctx.Row.Current, leftColIdx)
|
||||
mergeCurrentR := jr.getMergeState(jr.ctx.Row.Current, rightColIdx)
|
||||
mergeNextL := jr.getMergeState(jr.ctx.Row.Next, leftColIdx)
|
||||
mergeNextR := jr.getMergeState(jr.ctx.Row.Next, rightColIdx)
|
||||
|
||||
isSpannedCurrent := mergeCurrentL.Horizontal.Present && !mergeCurrentL.Horizontal.End
|
||||
isSpannedNext := mergeNextL.Horizontal.Present && !mergeNextL.Horizontal.End
|
||||
|
||||
vPassThruLStrict := (mergeCurrentL.Vertical.Present && mergeNextL.Vertical.Present && !mergeCurrentL.Vertical.End && !mergeNextL.Vertical.Start) ||
|
||||
(mergeCurrentL.Hierarchical.Present && mergeNextL.Hierarchical.Present && !mergeCurrentL.Hierarchical.End && !mergeNextL.Hierarchical.Start)
|
||||
vPassThruRStrict := (mergeCurrentR.Vertical.Present && mergeNextR.Vertical.Present && !mergeCurrentR.Vertical.End && !mergeNextR.Vertical.Start) ||
|
||||
(mergeCurrentR.Hierarchical.Present && mergeNextR.Hierarchical.Present && !mergeCurrentR.Hierarchical.End && !mergeNextR.Hierarchical.Start)
|
||||
|
||||
isTop := (jr.ctx.Level == tw.LevelHeader && jr.ctx.Row.Location == tw.LocationFirst) ||
|
||||
(jr.ctx.Level == tw.LevelBody && jr.ctx.Row.Location == tw.LocationFirst && len(jr.ctx.Row.Previous) == 0)
|
||||
isBottom := (jr.ctx.Level == tw.LevelFooter && jr.ctx.Row.Location == tw.LocationEnd) ||
|
||||
(jr.ctx.Level == tw.LevelBody && jr.ctx.Row.Location == tw.LocationEnd && !jr.ctx.HasFooter)
|
||||
isPreFooter := jr.ctx.Level == tw.LevelFooter && (jr.ctx.Row.Position == tw.Row || jr.ctx.Row.Position == tw.Header)
|
||||
|
||||
if isTop {
|
||||
if isSpannedNext {
|
||||
symbol := jr.sym.Row()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
symbol := jr.sym.TopMid()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
|
||||
if isBottom {
|
||||
if vPassThruLStrict && vPassThruRStrict {
|
||||
symbol := jr.sym.Column()
|
||||
return jr.separatorTint.Apply(symbol)
|
||||
}
|
||||
if vPassThruLStrict {
|
||||
symbol := jr.sym.MidLeft()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
if vPassThruRStrict {
|
||||
symbol := jr.sym.MidRight()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
if isSpannedCurrent {
|
||||
symbol := jr.sym.Row()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
symbol := jr.sym.BottomMid()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
|
||||
if isPreFooter {
|
||||
if vPassThruLStrict && vPassThruRStrict {
|
||||
symbol := jr.sym.Column()
|
||||
return jr.separatorTint.Apply(symbol)
|
||||
}
|
||||
if vPassThruLStrict {
|
||||
symbol := jr.sym.MidLeft()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
if vPassThruRStrict {
|
||||
symbol := jr.sym.MidRight()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
if mergeCurrentL.Horizontal.Present {
|
||||
if !mergeCurrentL.Horizontal.End && mergeCurrentR.Horizontal.Present && !mergeCurrentR.Horizontal.End {
|
||||
jr.logger.Debugf("Footer separator: H-merge continues from col %d to %d (mid-span), using BottomMid", leftColIdx, rightColIdx)
|
||||
symbol := jr.sym.BottomMid()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
if !mergeCurrentL.Horizontal.End && mergeCurrentR.Horizontal.Present && mergeCurrentR.Horizontal.End {
|
||||
jr.logger.Debugf("Footer separator: H-merge ends at col %d, using BottomMid", rightColIdx)
|
||||
symbol := jr.sym.BottomMid()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
if mergeCurrentL.Horizontal.End && !mergeCurrentR.Horizontal.Present {
|
||||
jr.logger.Debugf("Footer separator: H-merge ends at col %d, next col %d not merged, using Center", leftColIdx, rightColIdx)
|
||||
symbol := jr.sym.Center()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
}
|
||||
if isSpannedNext {
|
||||
symbol := jr.sym.BottomMid()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
if isSpannedCurrent {
|
||||
symbol := jr.sym.TopMid()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
symbol := jr.sym.Center()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
|
||||
if vPassThruLStrict && vPassThruRStrict {
|
||||
symbol := jr.sym.Column()
|
||||
return jr.separatorTint.Apply(symbol)
|
||||
}
|
||||
if vPassThruLStrict {
|
||||
symbol := jr.sym.MidLeft()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
if vPassThruRStrict {
|
||||
symbol := jr.sym.MidRight()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
if isSpannedCurrent && isSpannedNext {
|
||||
symbol := jr.sym.Row()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
if isSpannedCurrent {
|
||||
symbol := jr.sym.TopMid()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
if isSpannedNext {
|
||||
symbol := jr.sym.BottomMid()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
|
||||
symbol := jr.sym.Center()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
+415
@@ -0,0 +1,415 @@
|
||||
package renderer
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/olekukonko/ll"
|
||||
"github.com/olekukonko/tablewriter/pkg/twwidth"
|
||||
|
||||
"github.com/olekukonko/tablewriter/tw"
|
||||
)
|
||||
|
||||
// Markdown renders tables in Markdown format with customizable settings.
|
||||
type Markdown struct {
|
||||
config tw.Rendition // Rendering configuration
|
||||
logger *ll.Logger // Debug trace messages
|
||||
alignment tw.Alignment // alias of []tw.Align
|
||||
w io.Writer
|
||||
}
|
||||
|
||||
// NewMarkdown initializes a Markdown renderer with defaults tailored for Markdown (e.g., pipes, header separator).
|
||||
// Only the first config is used if multiple are provided.
|
||||
func NewMarkdown(configs ...tw.Rendition) *Markdown {
|
||||
cfg := defaultBlueprint()
|
||||
// Configure Markdown-specific defaults
|
||||
cfg.Symbols = tw.NewSymbols(tw.StyleMarkdown)
|
||||
cfg.Borders = tw.Border{Left: tw.On, Right: tw.On, Top: tw.Off, Bottom: tw.Off}
|
||||
cfg.Settings.Separators.BetweenColumns = tw.On
|
||||
cfg.Settings.Separators.BetweenRows = tw.Off
|
||||
cfg.Settings.Lines.ShowHeaderLine = tw.On
|
||||
cfg.Settings.Lines.ShowTop = tw.Off
|
||||
cfg.Settings.Lines.ShowBottom = tw.Off
|
||||
cfg.Settings.Lines.ShowFooterLine = tw.Off
|
||||
// cfg.Settings.TrimWhitespace = tw.On
|
||||
|
||||
// Apply user overrides
|
||||
if len(configs) > 0 {
|
||||
cfg = mergeMarkdownConfig(cfg, configs[0])
|
||||
}
|
||||
return &Markdown{config: cfg, logger: ll.New("markdown").Disable()}
|
||||
}
|
||||
|
||||
// mergeMarkdownConfig combines user-provided config with Markdown defaults, enforcing Markdown-specific settings.
|
||||
func mergeMarkdownConfig(defaults, overrides tw.Rendition) tw.Rendition {
|
||||
if overrides.Borders.Left != 0 {
|
||||
defaults.Borders.Left = overrides.Borders.Left
|
||||
}
|
||||
if overrides.Borders.Right != 0 {
|
||||
defaults.Borders.Right = overrides.Borders.Right
|
||||
}
|
||||
if overrides.Symbols != nil {
|
||||
defaults.Symbols = overrides.Symbols
|
||||
}
|
||||
defaults.Settings = mergeSettings(defaults.Settings, overrides.Settings)
|
||||
// Enforce Markdown requirements
|
||||
defaults.Settings.Lines.ShowHeaderLine = tw.On
|
||||
defaults.Settings.Separators.BetweenColumns = tw.On
|
||||
// defaults.Settings.TrimWhitespace = tw.On
|
||||
return defaults
|
||||
}
|
||||
|
||||
func (m *Markdown) Logger(logger *ll.Logger) {
|
||||
m.logger = logger.Namespace("markdown")
|
||||
}
|
||||
|
||||
// Config returns the renderer's current configuration.
|
||||
func (m *Markdown) Config() tw.Rendition {
|
||||
return m.config
|
||||
}
|
||||
|
||||
// Header renders the Markdown table header and its separator line.
|
||||
func (m *Markdown) Header(headers [][]string, ctx tw.Formatting) {
|
||||
m.resolveAlignment(ctx)
|
||||
if len(headers) == 0 || len(headers[0]) == 0 {
|
||||
m.logger.Debug("Header: No headers to render")
|
||||
return
|
||||
}
|
||||
m.logger.Debugf("Rendering header with %d lines, widths=%v, current=%v, next=%v", len(headers), ctx.Row.Widths, ctx.Row.Current, ctx.Row.Next)
|
||||
|
||||
// Render header content
|
||||
m.renderMarkdownLine(headers[0], ctx, false)
|
||||
|
||||
// Render separator if enabled
|
||||
if m.config.Settings.Lines.ShowHeaderLine.Enabled() {
|
||||
sepCtx := ctx
|
||||
sepCtx.Row.Widths = ctx.Row.Widths
|
||||
sepCtx.Row.Current = ctx.Row.Current
|
||||
sepCtx.Row.Previous = ctx.Row.Current
|
||||
sepCtx.IsSubRow = true
|
||||
m.renderMarkdownLine(nil, sepCtx, true)
|
||||
}
|
||||
}
|
||||
|
||||
// Row renders a Markdown table data row.
|
||||
func (m *Markdown) Row(row []string, ctx tw.Formatting) {
|
||||
m.resolveAlignment(ctx)
|
||||
m.logger.Debugf("Rendering row with data=%v, widths=%v, previous=%v, current=%v, next=%v", row, ctx.Row.Widths, ctx.Row.Previous, ctx.Row.Current, ctx.Row.Next)
|
||||
m.renderMarkdownLine(row, ctx, false)
|
||||
}
|
||||
|
||||
// Footer renders the Markdown table footer.
|
||||
func (m *Markdown) Footer(footers [][]string, ctx tw.Formatting) {
|
||||
m.resolveAlignment(ctx)
|
||||
if len(footers) == 0 || len(footers[0]) == 0 {
|
||||
m.logger.Debug("Footer: No footers to render")
|
||||
return
|
||||
}
|
||||
m.logger.Debugf("Rendering footer with %d lines, widths=%v, previous=%v, current=%v, next=%v",
|
||||
len(footers), ctx.Row.Widths, ctx.Row.Previous, ctx.Row.Current, ctx.Row.Next)
|
||||
m.renderMarkdownLine(footers[0], ctx, false)
|
||||
}
|
||||
|
||||
// Line is a no-op for Markdown, as only the header separator is rendered (handled by Header).
|
||||
func (m *Markdown) Line(ctx tw.Formatting) {
|
||||
m.logger.Debugf("Line: Generic Line call received (pos: %s, loc: %s). Markdown ignores these.", ctx.Row.Position, ctx.Row.Location)
|
||||
}
|
||||
|
||||
// Reset clears the renderer's internal state, including debug traces.
|
||||
func (m *Markdown) Reset() {
|
||||
m.logger.Info("Reset: Cleared debug trace")
|
||||
}
|
||||
|
||||
func (m *Markdown) Start(w io.Writer) error {
|
||||
m.w = w
|
||||
m.logger.Warn("Markdown.Start() called (no-op).")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Markdown) Close() error {
|
||||
m.logger.Warn("Markdown.Close() called (no-op).")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Markdown) resolveAlignment(ctx tw.Formatting) tw.Alignment {
|
||||
if len(m.alignment) != 0 {
|
||||
return m.alignment
|
||||
}
|
||||
|
||||
// get total columns
|
||||
total := len(ctx.Row.Current)
|
||||
|
||||
// build default alignment
|
||||
for i := 0; i < total; i++ {
|
||||
m.alignment = append(m.alignment, tw.AlignNone) // Default to AlignNone
|
||||
}
|
||||
|
||||
// add per column alignment if it exists
|
||||
for i := 0; i < total; i++ {
|
||||
m.alignment[i] = ctx.Row.Current[i].Align
|
||||
}
|
||||
|
||||
m.logger.Debugf(" → Align Resolved %s", m.alignment)
|
||||
return m.alignment
|
||||
}
|
||||
|
||||
// formatCell formats a Markdown cell's content with padding and alignment, ensuring at least 3 characters wide.
|
||||
func (m *Markdown) formatCell(content string, width int, align tw.Align, padding tw.Padding) string {
|
||||
// if m.config.Settings.TrimWhitespace.Enabled() {
|
||||
// content = strings.TrimSpace(content)
|
||||
//}
|
||||
contentVisualWidth := twwidth.Width(content)
|
||||
|
||||
// Use specified padding characters or default to spaces
|
||||
padLeftChar := padding.Left
|
||||
if padLeftChar == tw.Empty {
|
||||
padLeftChar = tw.Space
|
||||
}
|
||||
padRightChar := padding.Right
|
||||
if padRightChar == tw.Empty {
|
||||
padRightChar = tw.Space
|
||||
}
|
||||
|
||||
// Calculate padding widths
|
||||
padLeftCharWidth := twwidth.Width(padLeftChar)
|
||||
padRightCharWidth := twwidth.Width(padRightChar)
|
||||
minWidth := tw.Max(3, contentVisualWidth+padLeftCharWidth+padRightCharWidth)
|
||||
targetWidth := tw.Max(width, minWidth)
|
||||
|
||||
// Calculate padding
|
||||
totalPaddingNeeded := max(targetWidth-contentVisualWidth, 0)
|
||||
|
||||
var leftPadStr, rightPadStr string
|
||||
switch align {
|
||||
case tw.AlignRight:
|
||||
leftPadCount := tw.Max(0, totalPaddingNeeded-padRightCharWidth)
|
||||
rightPadCount := totalPaddingNeeded - leftPadCount
|
||||
leftPadStr = strings.Repeat(padLeftChar, leftPadCount)
|
||||
rightPadStr = strings.Repeat(padRightChar, rightPadCount)
|
||||
case tw.AlignCenter:
|
||||
leftPadCount := totalPaddingNeeded / 2
|
||||
rightPadCount := totalPaddingNeeded - leftPadCount
|
||||
if leftPadCount < padLeftCharWidth && totalPaddingNeeded >= padLeftCharWidth+padRightCharWidth {
|
||||
leftPadCount = padLeftCharWidth
|
||||
rightPadCount = totalPaddingNeeded - leftPadCount
|
||||
}
|
||||
if rightPadCount < padRightCharWidth && totalPaddingNeeded >= padLeftCharWidth+padRightCharWidth {
|
||||
rightPadCount = padRightCharWidth
|
||||
leftPadCount = totalPaddingNeeded - rightPadCount
|
||||
}
|
||||
leftPadStr = strings.Repeat(padLeftChar, leftPadCount)
|
||||
rightPadStr = strings.Repeat(padRightChar, rightPadCount)
|
||||
default: // AlignLeft
|
||||
rightPadCount := tw.Max(0, totalPaddingNeeded-padLeftCharWidth)
|
||||
leftPadCount := totalPaddingNeeded - rightPadCount
|
||||
leftPadStr = strings.Repeat(padLeftChar, leftPadCount)
|
||||
rightPadStr = strings.Repeat(padRightChar, rightPadCount)
|
||||
}
|
||||
|
||||
// Build result
|
||||
result := leftPadStr + content + rightPadStr
|
||||
|
||||
// Adjust width if needed
|
||||
finalWidth := twwidth.Width(result)
|
||||
if finalWidth != targetWidth {
|
||||
m.logger.Debugf("Markdown formatCell MISMATCH: content='%s', target_w=%d, paddingL='%s', paddingR='%s', align=%s -> result='%s', result_w=%d",
|
||||
content, targetWidth, padding.Left, padding.Right, align, result, finalWidth)
|
||||
adjNeeded := targetWidth - finalWidth
|
||||
if adjNeeded > 0 {
|
||||
adjStr := strings.Repeat(tw.Space, adjNeeded)
|
||||
switch align {
|
||||
case tw.AlignRight:
|
||||
result = adjStr + result
|
||||
case tw.AlignCenter:
|
||||
leftAdj := adjNeeded / 2
|
||||
rightAdj := adjNeeded - leftAdj
|
||||
result = strings.Repeat(tw.Space, leftAdj) + result + strings.Repeat(tw.Space, rightAdj)
|
||||
default:
|
||||
result += adjStr
|
||||
}
|
||||
} else {
|
||||
result = twwidth.Truncate(result, targetWidth)
|
||||
}
|
||||
m.logger.Debugf("Markdown formatCell Corrected: target_w=%d, result='%s', new_w=%d", targetWidth, result, twwidth.Width(result))
|
||||
}
|
||||
|
||||
m.logger.Debugf("Markdown formatCell: content='%s', width=%d, align=%s, paddingL='%s', paddingR='%s' -> '%s' (target %d)",
|
||||
content, width, align, padding.Left, padding.Right, result, targetWidth)
|
||||
return result
|
||||
}
|
||||
|
||||
// formatSeparator generates a Markdown separator (e.g., `---`, `:--`, `:-:`) with alignment indicators.
|
||||
func (m *Markdown) formatSeparator(width int, align tw.Align) string {
|
||||
targetWidth := tw.Max(3, width)
|
||||
var sb strings.Builder
|
||||
|
||||
switch align {
|
||||
case tw.AlignLeft:
|
||||
sb.WriteRune(':')
|
||||
sb.WriteString(strings.Repeat("-", targetWidth-1))
|
||||
case tw.AlignRight:
|
||||
sb.WriteString(strings.Repeat("-", targetWidth-1))
|
||||
sb.WriteRune(':')
|
||||
case tw.AlignCenter:
|
||||
sb.WriteRune(':')
|
||||
sb.WriteString(strings.Repeat("-", targetWidth-2))
|
||||
sb.WriteRune(':')
|
||||
case tw.AlignNone:
|
||||
sb.WriteString(strings.Repeat("-", targetWidth))
|
||||
default:
|
||||
sb.WriteString(strings.Repeat("-", targetWidth)) // Fallback
|
||||
}
|
||||
|
||||
result := sb.String()
|
||||
currentLen := twwidth.Width(result)
|
||||
if currentLen < targetWidth {
|
||||
result += strings.Repeat("-", targetWidth-currentLen)
|
||||
} else if currentLen > targetWidth {
|
||||
result = twwidth.Truncate(result, targetWidth)
|
||||
}
|
||||
|
||||
m.logger.Debugf("Markdown formatSeparator: width=%d, align=%s -> '%s'", width, align, result)
|
||||
return result
|
||||
}
|
||||
|
||||
// renderMarkdownLine renders a single Markdown line (header, row, footer, or separator) with pipes and alignment.
|
||||
func (m *Markdown) renderMarkdownLine(line []string, ctx tw.Formatting, isHeaderSep bool) {
|
||||
numCols := 0
|
||||
if len(ctx.Row.Widths) > 0 {
|
||||
maxKey := -1
|
||||
for k := range ctx.Row.Widths {
|
||||
if k > maxKey {
|
||||
maxKey = k
|
||||
}
|
||||
}
|
||||
numCols = maxKey + 1
|
||||
} else if len(ctx.Row.Current) > 0 {
|
||||
maxKey := -1
|
||||
for k := range ctx.Row.Current {
|
||||
if k > maxKey {
|
||||
maxKey = k
|
||||
}
|
||||
}
|
||||
numCols = maxKey + 1
|
||||
} else if len(line) > 0 && !isHeaderSep {
|
||||
numCols = len(line)
|
||||
}
|
||||
|
||||
if numCols == 0 && !isHeaderSep {
|
||||
m.logger.Debug("renderMarkdownLine: Skipping line with zero columns.")
|
||||
return
|
||||
}
|
||||
|
||||
var output strings.Builder
|
||||
prefix := m.config.Symbols.Column()
|
||||
if m.config.Borders.Left == tw.Off {
|
||||
prefix = tw.Empty
|
||||
}
|
||||
suffix := m.config.Symbols.Column()
|
||||
if m.config.Borders.Right == tw.Off {
|
||||
suffix = tw.Empty
|
||||
}
|
||||
separator := m.config.Symbols.Column()
|
||||
output.WriteString(prefix)
|
||||
|
||||
colIndex := 0
|
||||
separatorWidth := twwidth.Width(separator)
|
||||
|
||||
for colIndex < numCols {
|
||||
cellCtx, ok := ctx.Row.Current[colIndex]
|
||||
align := m.alignment[colIndex]
|
||||
|
||||
defaultPadding := tw.Padding{Left: tw.Space, Right: tw.Space}
|
||||
if !ok {
|
||||
cellCtx = tw.CellContext{
|
||||
Data: tw.Empty, Align: align, Padding: defaultPadding,
|
||||
Width: ctx.Row.Widths.Get(colIndex), Merge: tw.MergeState{},
|
||||
}
|
||||
} else if !cellCtx.Padding.Paddable() {
|
||||
cellCtx.Padding = defaultPadding
|
||||
}
|
||||
|
||||
// Add separator
|
||||
isContinuation := ok && cellCtx.Merge.Horizontal.Present && !cellCtx.Merge.Horizontal.Start
|
||||
if colIndex > 0 && !isContinuation {
|
||||
output.WriteString(separator)
|
||||
m.logger.Debugf("renderMarkdownLine: Added separator '%s' before col %d", separator, colIndex)
|
||||
}
|
||||
|
||||
// Calculate width and span
|
||||
span := 1
|
||||
visualWidth := 0
|
||||
isHMergeStart := ok && cellCtx.Merge.Horizontal.Present && cellCtx.Merge.Horizontal.Start
|
||||
if isHMergeStart {
|
||||
span = cellCtx.Merge.Horizontal.Span
|
||||
totalWidth := 0
|
||||
for k := 0; k < span && colIndex+k < numCols; k++ {
|
||||
colWidth := max(ctx.NormalizedWidths.Get(colIndex+k), 0)
|
||||
totalWidth += colWidth
|
||||
if k > 0 && separatorWidth > 0 {
|
||||
totalWidth += separatorWidth
|
||||
}
|
||||
}
|
||||
visualWidth = totalWidth
|
||||
m.logger.Debugf("renderMarkdownLine: HMerge col %d, span %d, visualWidth %d", colIndex, span, visualWidth)
|
||||
} else {
|
||||
visualWidth = ctx.Row.Widths.Get(colIndex)
|
||||
m.logger.Debugf("renderMarkdownLine: Regular col %d, visualWidth %d", colIndex, visualWidth)
|
||||
}
|
||||
if visualWidth < 0 {
|
||||
visualWidth = 0
|
||||
}
|
||||
|
||||
// Render segment
|
||||
if isContinuation {
|
||||
m.logger.Debugf("renderMarkdownLine: Skipping col %d (HMerge continuation)", colIndex)
|
||||
} else {
|
||||
var formattedSegment string
|
||||
if isHeaderSep {
|
||||
// Use header's alignment from ctx.Row.Previous
|
||||
headerAlign := align
|
||||
if headerCellCtx, headerOK := ctx.Row.Previous[colIndex]; headerOK {
|
||||
headerAlign = headerCellCtx.Align
|
||||
// Preserve tw.AlignNone for separator
|
||||
if headerAlign != tw.AlignNone && (headerAlign == tw.Empty || headerAlign == tw.Skip) {
|
||||
headerAlign = tw.AlignCenter
|
||||
}
|
||||
}
|
||||
formattedSegment = m.formatSeparator(visualWidth, headerAlign)
|
||||
} else {
|
||||
content := tw.Empty
|
||||
if colIndex < len(line) {
|
||||
content = line[colIndex]
|
||||
}
|
||||
// For rows, use the header's alignment if specified
|
||||
rowAlign := align
|
||||
if headerCellCtx, headerOK := ctx.Row.Previous[colIndex]; headerOK && !isHeaderSep {
|
||||
if headerCellCtx.Align != tw.AlignNone && headerCellCtx.Align != tw.Empty {
|
||||
rowAlign = headerCellCtx.Align
|
||||
}
|
||||
}
|
||||
if rowAlign == tw.AlignNone || rowAlign == tw.Empty {
|
||||
switch ctx.Row.Position {
|
||||
case tw.Header:
|
||||
rowAlign = tw.AlignCenter
|
||||
case tw.Footer:
|
||||
rowAlign = tw.AlignRight
|
||||
default:
|
||||
rowAlign = tw.AlignLeft
|
||||
}
|
||||
m.logger.Debugf("renderMarkdownLine: Col %d using default align '%s'", colIndex, rowAlign)
|
||||
}
|
||||
formattedSegment = m.formatCell(content, visualWidth, rowAlign, cellCtx.Padding)
|
||||
}
|
||||
output.WriteString(formattedSegment)
|
||||
m.logger.Debugf("renderMarkdownLine: Wrote col %d (span %d, width %d): '%s'", colIndex, span, visualWidth, formattedSegment)
|
||||
}
|
||||
|
||||
colIndex += span
|
||||
}
|
||||
|
||||
output.WriteString(suffix)
|
||||
output.WriteString(tw.NewLine)
|
||||
m.w.Write([]byte(output.String()))
|
||||
m.logger.Debugf("renderMarkdownLine: Final line: %s", strings.TrimSuffix(output.String(), tw.NewLine))
|
||||
}
|
||||
+462
@@ -0,0 +1,462 @@
|
||||
package renderer
|
||||
|
||||
import (
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/olekukonko/tablewriter/pkg/twwidth"
|
||||
|
||||
"github.com/olekukonko/ll"
|
||||
"github.com/olekukonko/tablewriter/tw"
|
||||
)
|
||||
|
||||
// OceanConfig defines configuration specific to the Ocean renderer.
|
||||
type OceanConfig struct{}
|
||||
|
||||
// Ocean is a streaming table renderer that writes ASCII tables.
|
||||
type Ocean struct {
|
||||
config tw.Rendition
|
||||
oceanConfig OceanConfig
|
||||
fixedWidths tw.Mapper[int, int]
|
||||
widthsFinalized bool
|
||||
tableOutputStarted bool
|
||||
headerContentRendered bool // True if actual header *content* has been rendered by Ocean.Header
|
||||
logger *ll.Logger
|
||||
w io.Writer
|
||||
}
|
||||
|
||||
func NewOcean(oceanConfig ...OceanConfig) *Ocean {
|
||||
cfg := defaultOceanRendererConfig()
|
||||
oCfg := OceanConfig{}
|
||||
if len(oceanConfig) > 0 {
|
||||
// Apply user-provided OceanConfig if necessary
|
||||
}
|
||||
r := &Ocean{
|
||||
config: cfg,
|
||||
oceanConfig: oCfg,
|
||||
fixedWidths: tw.NewMapper[int, int](),
|
||||
logger: ll.New("ocean").Disable(),
|
||||
}
|
||||
r.resetState()
|
||||
return r
|
||||
}
|
||||
|
||||
func (o *Ocean) resetState() {
|
||||
o.fixedWidths = tw.NewMapper[int, int]()
|
||||
o.widthsFinalized = false
|
||||
o.tableOutputStarted = false
|
||||
o.headerContentRendered = false
|
||||
o.logger.Debug("State reset.")
|
||||
}
|
||||
|
||||
func (o *Ocean) Logger(logger *ll.Logger) {
|
||||
o.logger = logger.Namespace("ocean")
|
||||
}
|
||||
|
||||
func (o *Ocean) Config() tw.Rendition {
|
||||
return o.config
|
||||
}
|
||||
|
||||
func (o *Ocean) tryFinalizeWidths(ctx tw.Formatting) {
|
||||
if o.widthsFinalized {
|
||||
return
|
||||
}
|
||||
if ctx.Row.Widths != nil && ctx.Row.Widths.Len() > 0 {
|
||||
o.fixedWidths = ctx.Row.Widths.Clone()
|
||||
o.widthsFinalized = true
|
||||
o.logger.Debugf("Widths finalized from context: %v", o.fixedWidths)
|
||||
} else {
|
||||
o.logger.Warn("Attempted to finalize widths, but no width data in context.")
|
||||
}
|
||||
}
|
||||
|
||||
func (o *Ocean) Start(w io.Writer) error {
|
||||
o.w = w
|
||||
o.logger.Debug("Start() called.")
|
||||
o.resetState()
|
||||
// Top border is drawn by the first component (Header or Row) that has widths
|
||||
// OR by an explicit Line() call from table.go's batch renderer.
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderTopBorderIfNeeded is called by Header or Row if it's the first to render
|
||||
// and tableOutputStarted is false.
|
||||
func (o *Ocean) renderTopBorderIfNeeded(currentPosition tw.Position, ctx tw.Formatting) {
|
||||
if !o.tableOutputStarted && o.widthsFinalized {
|
||||
// This renderer's config for Top border
|
||||
if o.config.Borders.Top.Enabled() && o.config.Settings.Lines.ShowTop.Enabled() {
|
||||
o.logger.Debugf("Ocean itself rendering top border (triggered by %s)", currentPosition)
|
||||
lineCtx := tw.Formatting{ // Construct specific context for this line
|
||||
Row: tw.RowContext{
|
||||
Widths: o.fixedWidths,
|
||||
Location: tw.LocationFirst,
|
||||
Position: currentPosition,
|
||||
Next: ctx.Row.Current, // The actual first content is "Next" to the top border
|
||||
},
|
||||
Level: tw.LevelHeader,
|
||||
}
|
||||
o.Line(lineCtx)
|
||||
o.tableOutputStarted = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (o *Ocean) Header(headers [][]string, ctx tw.Formatting) {
|
||||
o.logger.Debugf("Ocean.Header called: IsSubRow=%v, Location=%v, NumLines=%d", ctx.IsSubRow, ctx.Row.Location, len(headers))
|
||||
|
||||
if !o.widthsFinalized {
|
||||
o.tryFinalizeWidths(ctx)
|
||||
}
|
||||
|
||||
if !o.widthsFinalized {
|
||||
o.logger.Error("Ocean.Header: Cannot render content, widths are not finalized.")
|
||||
return
|
||||
}
|
||||
|
||||
if len(headers) > 0 && len(headers[0]) > 0 {
|
||||
for i, headerLineData := range headers {
|
||||
currentLineCtx := ctx
|
||||
currentLineCtx.Row.Widths = o.fixedWidths
|
||||
if i > 0 {
|
||||
currentLineCtx.IsSubRow = true
|
||||
}
|
||||
o.renderContentLine(currentLineCtx, headerLineData)
|
||||
o.tableOutputStarted = true // Content was written
|
||||
}
|
||||
o.headerContentRendered = true
|
||||
} else {
|
||||
o.logger.Debug("Ocean.Header: No actual header content lines to render.")
|
||||
}
|
||||
}
|
||||
|
||||
func (o *Ocean) Row(row []string, ctx tw.Formatting) {
|
||||
o.logger.Debugf("Ocean.Row called: IsSubRow=%v, Location=%v, DataItems=%d", ctx.IsSubRow, ctx.Row.Location, len(row))
|
||||
|
||||
if !o.widthsFinalized {
|
||||
o.tryFinalizeWidths(ctx)
|
||||
}
|
||||
if !o.widthsFinalized {
|
||||
o.logger.Error("Ocean.Row: Cannot render content, widths are not finalized.")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Row.Widths = o.fixedWidths
|
||||
o.renderContentLine(ctx, row)
|
||||
o.tableOutputStarted = true
|
||||
}
|
||||
|
||||
func (o *Ocean) Footer(footers [][]string, ctx tw.Formatting) {
|
||||
o.logger.Debugf("Ocean.Footer called: IsSubRow=%v, Location=%v, NumLines=%d", ctx.IsSubRow, ctx.Row.Location, len(footers))
|
||||
|
||||
if !o.widthsFinalized {
|
||||
o.tryFinalizeWidths(ctx)
|
||||
o.logger.Warn("Ocean.Footer: Widths finalized at Footer stage (unusual).")
|
||||
}
|
||||
if !o.widthsFinalized {
|
||||
o.logger.Error("Ocean.Footer: Cannot render content, widths are not finalized.")
|
||||
return
|
||||
}
|
||||
|
||||
if len(footers) > 0 && len(footers[0]) > 0 {
|
||||
for i, footerLineData := range footers {
|
||||
currentLineCtx := ctx
|
||||
currentLineCtx.Row.Widths = o.fixedWidths
|
||||
if i > 0 {
|
||||
currentLineCtx.IsSubRow = true
|
||||
}
|
||||
o.renderContentLine(currentLineCtx, footerLineData)
|
||||
o.tableOutputStarted = true
|
||||
}
|
||||
} else {
|
||||
o.logger.Debug("Ocean.Footer: No actual footer content lines to render.")
|
||||
}
|
||||
}
|
||||
|
||||
func (o *Ocean) Line(ctx tw.Formatting) {
|
||||
if !o.widthsFinalized {
|
||||
o.tryFinalizeWidths(ctx)
|
||||
if !o.widthsFinalized {
|
||||
o.logger.Error("Ocean.Line: Called but widths could not be finalized. Skipping line rendering.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure Line uses the consistent fixedWidths for drawing
|
||||
ctx.Row.Widths = o.fixedWidths
|
||||
o.logger.Debugf("Ocean.Line DRAWING: Level=%v, Loc=%s, Pos=%s, IsSubRow=%t, WidthsLen=%d", ctx.Level, ctx.Row.Location, ctx.Row.Position, ctx.IsSubRow, ctx.Row.Widths.Len())
|
||||
|
||||
jr := NewJunction(JunctionContext{
|
||||
Symbols: o.config.Symbols,
|
||||
Ctx: ctx,
|
||||
ColIdx: 0,
|
||||
Logger: o.logger,
|
||||
BorderTint: Tint{},
|
||||
SeparatorTint: Tint{},
|
||||
})
|
||||
|
||||
var line strings.Builder
|
||||
sortedColIndices := o.fixedWidths.SortedKeys()
|
||||
|
||||
if len(sortedColIndices) == 0 {
|
||||
drewEmptyBorders := false
|
||||
if o.config.Borders.Left.Enabled() {
|
||||
line.WriteString(jr.RenderLeft())
|
||||
drewEmptyBorders = true
|
||||
}
|
||||
if o.config.Borders.Right.Enabled() {
|
||||
line.WriteString(jr.RenderRight(-1))
|
||||
drewEmptyBorders = true
|
||||
}
|
||||
if drewEmptyBorders {
|
||||
line.WriteString(tw.NewLine)
|
||||
o.w.Write([]byte(line.String()))
|
||||
o.logger.Debug("Line: Drew empty table borders based on Line call.")
|
||||
} else {
|
||||
o.logger.Debug("Line: Handled empty table case (no columns, no borders).")
|
||||
}
|
||||
o.tableOutputStarted = drewEmptyBorders // A line counts as output
|
||||
return
|
||||
}
|
||||
|
||||
if o.config.Borders.Left.Enabled() {
|
||||
line.WriteString(jr.RenderLeft())
|
||||
}
|
||||
|
||||
for i, colIdx := range sortedColIndices {
|
||||
jr.colIdx = colIdx
|
||||
segmentChar := jr.GetSegment()
|
||||
colVisualWidth := o.fixedWidths.Get(colIdx)
|
||||
|
||||
if colVisualWidth <= 0 {
|
||||
// Still need to consider separators after zero-width columns
|
||||
} else {
|
||||
if segmentChar == tw.Empty {
|
||||
segmentChar = o.config.Symbols.Row()
|
||||
}
|
||||
segmentDisplayWidth := twwidth.Width(segmentChar)
|
||||
if segmentDisplayWidth <= 0 {
|
||||
segmentDisplayWidth = 1
|
||||
}
|
||||
|
||||
repeatCount := 0
|
||||
if colVisualWidth > 0 {
|
||||
repeatCount = colVisualWidth / segmentDisplayWidth
|
||||
if repeatCount == 0 {
|
||||
repeatCount = 1
|
||||
}
|
||||
}
|
||||
line.WriteString(strings.Repeat(segmentChar, repeatCount))
|
||||
}
|
||||
|
||||
if i < len(sortedColIndices)-1 && o.config.Settings.Separators.BetweenColumns.Enabled() {
|
||||
nextColIdx := sortedColIndices[i+1]
|
||||
line.WriteString(jr.RenderJunction(colIdx, nextColIdx))
|
||||
}
|
||||
}
|
||||
|
||||
if o.config.Borders.Right.Enabled() {
|
||||
lastColIdx := sortedColIndices[len(sortedColIndices)-1]
|
||||
line.WriteString(jr.RenderRight(lastColIdx))
|
||||
}
|
||||
|
||||
line.WriteString(tw.NewLine)
|
||||
o.w.Write([]byte(line.String()))
|
||||
o.tableOutputStarted = true
|
||||
o.logger.Debugf("Line rendered by explicit call: %s", strings.TrimSuffix(line.String(), tw.NewLine))
|
||||
}
|
||||
|
||||
func (o *Ocean) Close() error {
|
||||
o.logger.Debug("Ocean.Close() called.")
|
||||
o.resetState()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *Ocean) renderContentLine(ctx tw.Formatting, lineData []string) {
|
||||
if !o.widthsFinalized || o.fixedWidths.Len() == 0 {
|
||||
o.logger.Error("renderContentLine: Cannot render, fixedWidths not set or empty.")
|
||||
return
|
||||
}
|
||||
|
||||
var output strings.Builder
|
||||
if o.config.Borders.Left.Enabled() {
|
||||
output.WriteString(o.config.Symbols.Column())
|
||||
}
|
||||
|
||||
sortedColIndices := o.fixedWidths.SortedKeys()
|
||||
|
||||
for i, colIdx := range sortedColIndices {
|
||||
cellVisualWidth := o.fixedWidths.Get(colIdx)
|
||||
cellContent := tw.Empty
|
||||
align := tw.AlignDefault
|
||||
padding := tw.Padding{Left: tw.Space, Right: tw.Space}
|
||||
|
||||
switch ctx.Row.Position {
|
||||
case tw.Header:
|
||||
align = tw.AlignCenter
|
||||
case tw.Footer:
|
||||
align = tw.AlignRight
|
||||
default:
|
||||
align = tw.AlignLeft
|
||||
}
|
||||
|
||||
cellCtx, hasCellCtx := ctx.Row.Current[colIdx]
|
||||
if hasCellCtx {
|
||||
cellContent = cellCtx.Data
|
||||
if cellCtx.Align.Validate() == nil && cellCtx.Align != tw.AlignNone {
|
||||
align = cellCtx.Align
|
||||
}
|
||||
if cellCtx.Padding.Paddable() {
|
||||
padding = cellCtx.Padding
|
||||
}
|
||||
} else if colIdx < len(lineData) {
|
||||
cellContent = lineData[colIdx]
|
||||
}
|
||||
|
||||
actualCellWidthToRender := cellVisualWidth
|
||||
isHMergeContinuation := false
|
||||
|
||||
if hasCellCtx && cellCtx.Merge.Horizontal.Present {
|
||||
if cellCtx.Merge.Horizontal.Start {
|
||||
hSpan := cellCtx.Merge.Horizontal.Span
|
||||
if hSpan <= 0 {
|
||||
hSpan = 1
|
||||
}
|
||||
|
||||
currentMergeTotalRenderWidth := 0
|
||||
for k := 0; k < hSpan; k++ {
|
||||
idxInMergeSpan := colIdx + k
|
||||
// Check if idxInMergeSpan is a defined column in fixedWidths
|
||||
foundInFixedWidths := false
|
||||
if slices.Contains(sortedColIndices, idxInMergeSpan) {
|
||||
currentMergeTotalRenderWidth += o.fixedWidths.Get(idxInMergeSpan)
|
||||
foundInFixedWidths = true
|
||||
}
|
||||
if !foundInFixedWidths && idxInMergeSpan <= sortedColIndices[len(sortedColIndices)-1] {
|
||||
o.logger.Debugf("Col %d in HMerge span not found in fixedWidths, assuming 0-width contribution.", idxInMergeSpan)
|
||||
}
|
||||
|
||||
if k < hSpan-1 && o.config.Settings.Separators.BetweenColumns.Enabled() {
|
||||
currentMergeTotalRenderWidth += twwidth.Width(o.config.Symbols.Column())
|
||||
}
|
||||
}
|
||||
actualCellWidthToRender = currentMergeTotalRenderWidth
|
||||
} else {
|
||||
isHMergeContinuation = true
|
||||
}
|
||||
}
|
||||
|
||||
if isHMergeContinuation {
|
||||
o.logger.Debugf("renderContentLine: Col %d is HMerge continuation, skipping content render.", colIdx)
|
||||
// The separator logic below needs to handle this correctly.
|
||||
// If the *previous* column was the start of a merge that spans *this* column,
|
||||
// then the separator after the previous column should have been suppressed.
|
||||
} else if actualCellWidthToRender > 0 {
|
||||
formattedCell := o.formatCellContent(cellContent, actualCellWidthToRender, padding, align)
|
||||
output.WriteString(formattedCell)
|
||||
} else {
|
||||
o.logger.Debugf("renderContentLine: col %d has 0 render width, writing no content.", colIdx)
|
||||
}
|
||||
|
||||
// Add column separator if:
|
||||
// 1. It's not the last column in sortedColIndices
|
||||
// 2. Separators are enabled
|
||||
// 3. This cell is NOT a horizontal merge start that spans over the next column.
|
||||
if i < len(sortedColIndices)-1 && o.config.Settings.Separators.BetweenColumns.Enabled() {
|
||||
shouldAddSeparator := true
|
||||
if hasCellCtx && cellCtx.Merge.Horizontal.Present && cellCtx.Merge.Horizontal.Start {
|
||||
// If this merge start spans beyond the current colIdx into the next sortedColIndex
|
||||
if colIdx+cellCtx.Merge.Horizontal.Span > sortedColIndices[i+1] {
|
||||
shouldAddSeparator = false // Separator is part of the merged cell's width
|
||||
o.logger.Debugf("renderContentLine: Suppressed separator after HMerge col %d.", colIdx)
|
||||
}
|
||||
}
|
||||
if shouldAddSeparator {
|
||||
output.WriteString(o.config.Symbols.Column())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if o.config.Borders.Right.Enabled() {
|
||||
output.WriteString(o.config.Symbols.Column())
|
||||
}
|
||||
|
||||
output.WriteString(tw.NewLine)
|
||||
o.w.Write([]byte(output.String()))
|
||||
o.logger.Debugf("Content line rendered: %s", strings.TrimSuffix(output.String(), tw.NewLine))
|
||||
}
|
||||
|
||||
func (o *Ocean) formatCellContent(content string, cellVisualWidth int, padding tw.Padding, align tw.Align) string {
|
||||
if cellVisualWidth <= 0 {
|
||||
return tw.Empty
|
||||
}
|
||||
|
||||
contentDisplayWidth := twwidth.Width(content)
|
||||
|
||||
padLeftChar := padding.Left
|
||||
if padLeftChar == tw.Empty {
|
||||
padLeftChar = tw.Space
|
||||
}
|
||||
padRightChar := padding.Right
|
||||
if padRightChar == tw.Empty {
|
||||
padRightChar = tw.Space
|
||||
}
|
||||
|
||||
padLeftDisplayWidth := twwidth.Width(padLeftChar)
|
||||
padRightDisplayWidth := twwidth.Width(padRightChar)
|
||||
|
||||
spaceForContentAndAlignment := max(cellVisualWidth-padLeftDisplayWidth-padRightDisplayWidth, 0)
|
||||
|
||||
if contentDisplayWidth > spaceForContentAndAlignment {
|
||||
content = twwidth.Truncate(content, spaceForContentAndAlignment)
|
||||
contentDisplayWidth = twwidth.Width(content)
|
||||
}
|
||||
|
||||
remainingSpace := max(spaceForContentAndAlignment-contentDisplayWidth, 0)
|
||||
|
||||
var PL, PR string
|
||||
switch align {
|
||||
case tw.AlignRight:
|
||||
PL = strings.Repeat(tw.Space, remainingSpace)
|
||||
case tw.AlignCenter:
|
||||
leftSpaces := remainingSpace / 2
|
||||
rightSpaces := remainingSpace - leftSpaces
|
||||
PL = strings.Repeat(tw.Space, leftSpaces)
|
||||
PR = strings.Repeat(tw.Space, rightSpaces)
|
||||
default:
|
||||
PR = strings.Repeat(tw.Space, remainingSpace)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(padLeftChar)
|
||||
sb.WriteString(PL)
|
||||
sb.WriteString(content)
|
||||
sb.WriteString(PR)
|
||||
sb.WriteString(padRightChar)
|
||||
|
||||
currentFormattedWidth := twwidth.Width(sb.String())
|
||||
if currentFormattedWidth < cellVisualWidth {
|
||||
if align == tw.AlignRight {
|
||||
prefixSpaces := strings.Repeat(tw.Space, cellVisualWidth-currentFormattedWidth)
|
||||
finalStr := prefixSpaces + sb.String()
|
||||
sb.Reset()
|
||||
sb.WriteString(finalStr)
|
||||
} else {
|
||||
sb.WriteString(strings.Repeat(tw.Space, cellVisualWidth-currentFormattedWidth))
|
||||
}
|
||||
} else if currentFormattedWidth > cellVisualWidth {
|
||||
tempStr := sb.String()
|
||||
sb.Reset()
|
||||
sb.WriteString(twwidth.Truncate(tempStr, cellVisualWidth))
|
||||
o.logger.Warnf("formatCellContent: Final string '%s' (width %d) exceeded target %d. Force truncated.", tempStr, currentFormattedWidth, cellVisualWidth)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (o *Ocean) Rendition(config tw.Rendition) {
|
||||
o.config = mergeRendition(o.config, config)
|
||||
o.logger.Debugf("Blueprint.Rendition updated. New internal config: %+v", o.config)
|
||||
}
|
||||
|
||||
// Ensure Blueprint implements tw.Renditioning
|
||||
var _ tw.Renditioning = (*Ocean)(nil)
|
||||
+702
@@ -0,0 +1,702 @@
|
||||
package renderer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/olekukonko/ll"
|
||||
|
||||
"github.com/olekukonko/tablewriter/tw"
|
||||
)
|
||||
|
||||
// SVGConfig holds configuration for the SVG renderer.
|
||||
// Fields include font, colors, padding, and merge rendering options.
|
||||
// Used to customize SVG output appearance and behavior.
|
||||
type SVGConfig struct {
|
||||
FontFamily string // e.g., "Arial, sans-serif"
|
||||
FontSize float64 // Base font size in SVG units
|
||||
LineHeightFactor float64 // Factor for line height (e.g., 1.2)
|
||||
Padding float64 // Padding inside cells
|
||||
StrokeWidth float64 // Line width for borders
|
||||
StrokeColor string // Color for strokes (e.g., "black")
|
||||
HeaderBG string // Background color for header
|
||||
RowBG string // Background color for rows
|
||||
RowAltBG string // Alternating row background color
|
||||
FooterBG string // Background color for footer
|
||||
HeaderColor string // Text color for header
|
||||
RowColor string // Text color for rows
|
||||
FooterColor string // Text color for footer
|
||||
ApproxCharWidthFactor float64 // Char width relative to FontSize
|
||||
MinColWidth float64 // Minimum column width
|
||||
RenderTWConfigOverrides bool // Override SVG alignments with tablewriter
|
||||
Debug bool // Enable debug logging
|
||||
ScaleFactor float64 // Scaling factor for SVG
|
||||
}
|
||||
|
||||
// SVG implements tw.Renderer for SVG output.
|
||||
// Manages SVG element generation and merge tracking.
|
||||
type SVG struct {
|
||||
config SVGConfig
|
||||
trace []string
|
||||
|
||||
allVisualLineData [][][]string // [section][line][cell]
|
||||
allVisualLineCtx [][]tw.Formatting // [section][line]Formatting
|
||||
|
||||
maxCols int
|
||||
calculatedColWidths []float64
|
||||
svgElements strings.Builder
|
||||
currentY float64
|
||||
dataRowCounter int
|
||||
vMergeTrack map[int]int // Tracks vertical merge spans
|
||||
numVisualRowsDrawn int
|
||||
logger *ll.Logger
|
||||
w io.Writer
|
||||
}
|
||||
|
||||
const (
|
||||
sectionTypeHeader = 0
|
||||
sectionTypeRow = 1
|
||||
sectionTypeFooter = 2
|
||||
)
|
||||
|
||||
// NewSVG creates a new SVG renderer with configuration.
|
||||
// Parameter configs provides optional SVGConfig; defaults used if empty.
|
||||
// Returns a configured SVG instance.
|
||||
func NewSVG(configs ...SVGConfig) *SVG {
|
||||
cfg := SVGConfig{
|
||||
FontFamily: "sans-serif",
|
||||
FontSize: 12.0,
|
||||
LineHeightFactor: 1.4,
|
||||
Padding: 5.0,
|
||||
StrokeWidth: 1.0,
|
||||
StrokeColor: "black",
|
||||
HeaderBG: "#F0F0F0",
|
||||
RowBG: "white",
|
||||
RowAltBG: "#F9F9F9",
|
||||
FooterBG: "#F0F0F0",
|
||||
HeaderColor: "black",
|
||||
RowColor: "black",
|
||||
FooterColor: "black",
|
||||
ApproxCharWidthFactor: 0.6,
|
||||
MinColWidth: 30.0,
|
||||
ScaleFactor: 1.0,
|
||||
RenderTWConfigOverrides: true,
|
||||
Debug: false,
|
||||
}
|
||||
if len(configs) > 0 {
|
||||
userCfg := configs[0]
|
||||
if userCfg.FontFamily != tw.Empty {
|
||||
cfg.FontFamily = userCfg.FontFamily
|
||||
}
|
||||
if userCfg.FontSize > 0 {
|
||||
cfg.FontSize = userCfg.FontSize
|
||||
}
|
||||
if userCfg.LineHeightFactor > 0 {
|
||||
cfg.LineHeightFactor = userCfg.LineHeightFactor
|
||||
}
|
||||
if userCfg.Padding >= 0 {
|
||||
cfg.Padding = userCfg.Padding
|
||||
}
|
||||
if userCfg.StrokeWidth > 0 {
|
||||
cfg.StrokeWidth = userCfg.StrokeWidth
|
||||
}
|
||||
if userCfg.StrokeColor != tw.Empty {
|
||||
cfg.StrokeColor = userCfg.StrokeColor
|
||||
}
|
||||
if userCfg.HeaderBG != tw.Empty {
|
||||
cfg.HeaderBG = userCfg.HeaderBG
|
||||
}
|
||||
if userCfg.RowBG != tw.Empty {
|
||||
cfg.RowBG = userCfg.RowBG
|
||||
}
|
||||
cfg.RowAltBG = userCfg.RowAltBG
|
||||
if userCfg.FooterBG != tw.Empty {
|
||||
cfg.FooterBG = userCfg.FooterBG
|
||||
}
|
||||
if userCfg.HeaderColor != tw.Empty {
|
||||
cfg.HeaderColor = userCfg.HeaderColor
|
||||
}
|
||||
if userCfg.RowColor != tw.Empty {
|
||||
cfg.RowColor = userCfg.RowColor
|
||||
}
|
||||
if userCfg.FooterColor != tw.Empty {
|
||||
cfg.FooterColor = userCfg.FooterColor
|
||||
}
|
||||
if userCfg.ApproxCharWidthFactor > 0 {
|
||||
cfg.ApproxCharWidthFactor = userCfg.ApproxCharWidthFactor
|
||||
}
|
||||
if userCfg.MinColWidth >= 0 {
|
||||
cfg.MinColWidth = userCfg.MinColWidth
|
||||
}
|
||||
cfg.RenderTWConfigOverrides = userCfg.RenderTWConfigOverrides
|
||||
cfg.Debug = userCfg.Debug
|
||||
}
|
||||
r := &SVG{
|
||||
config: cfg,
|
||||
trace: make([]string, 0, 50),
|
||||
allVisualLineData: make([][][]string, 3),
|
||||
allVisualLineCtx: make([][]tw.Formatting, 3),
|
||||
vMergeTrack: make(map[int]int),
|
||||
logger: ll.New("svg").Disable(),
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
r.allVisualLineData[i] = make([][]string, 0)
|
||||
r.allVisualLineCtx[i] = make([]tw.Formatting, 0)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// calculateAllColumnWidths computes column widths based on content and merges.
|
||||
// Uses content length and merge spans; handles horizontal merges by distributing width.
|
||||
func (s *SVG) calculateAllColumnWidths() {
|
||||
s.debug("Calculating column widths")
|
||||
tempMaxCols := 0
|
||||
for sectionIdx := 0; sectionIdx < 3; sectionIdx++ {
|
||||
for lineIdx, lineCtx := range s.allVisualLineCtx[sectionIdx] {
|
||||
if lineCtx.Row.Current != nil {
|
||||
visualColCount := 0
|
||||
for colIdx := 0; colIdx < len(lineCtx.Row.Current); {
|
||||
cellCtx := lineCtx.Row.Current[colIdx]
|
||||
if cellCtx.Merge.Horizontal.Present && !cellCtx.Merge.Horizontal.Start {
|
||||
colIdx++ // Skip non-start merged cells
|
||||
continue
|
||||
}
|
||||
visualColCount++
|
||||
span := 1
|
||||
if cellCtx.Merge.Horizontal.Present && cellCtx.Merge.Horizontal.Start {
|
||||
span = cellCtx.Merge.Horizontal.Span
|
||||
if span <= 0 {
|
||||
span = 1
|
||||
}
|
||||
}
|
||||
colIdx += span
|
||||
}
|
||||
s.debug("Section %d, line %d: Visual columns = %d", sectionIdx, lineIdx, visualColCount)
|
||||
if visualColCount > tempMaxCols {
|
||||
tempMaxCols = visualColCount
|
||||
}
|
||||
} else if lineIdx < len(s.allVisualLineData[sectionIdx]) {
|
||||
if rawDataLen := len(s.allVisualLineData[sectionIdx][lineIdx]); rawDataLen > tempMaxCols {
|
||||
tempMaxCols = rawDataLen
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
s.maxCols = tempMaxCols
|
||||
s.debug("Max columns: %d", s.maxCols)
|
||||
if s.maxCols == 0 {
|
||||
s.calculatedColWidths = []float64{}
|
||||
return
|
||||
}
|
||||
s.calculatedColWidths = make([]float64, s.maxCols)
|
||||
for i := range s.calculatedColWidths {
|
||||
s.calculatedColWidths[i] = s.config.MinColWidth
|
||||
}
|
||||
|
||||
// Structure to track max width for each merge group
|
||||
type mergeKey struct {
|
||||
startCol int
|
||||
span int
|
||||
}
|
||||
maxMergeWidths := make(map[mergeKey]float64)
|
||||
|
||||
processSectionForWidth := func(sectionIdx int) {
|
||||
for lineIdx, visualLineData := range s.allVisualLineData[sectionIdx] {
|
||||
if lineIdx >= len(s.allVisualLineCtx[sectionIdx]) {
|
||||
s.debug("Warning: Missing context for section %d line %d", sectionIdx, lineIdx)
|
||||
continue
|
||||
}
|
||||
lineCtx := s.allVisualLineCtx[sectionIdx][lineIdx]
|
||||
currentTableCol := 0
|
||||
currentVisualCol := 0
|
||||
for currentVisualCol < len(visualLineData) && currentTableCol < s.maxCols {
|
||||
cellContent := visualLineData[currentVisualCol]
|
||||
cellCtx := tw.CellContext{}
|
||||
if lineCtx.Row.Current != nil {
|
||||
if c, ok := lineCtx.Row.Current[currentTableCol]; ok {
|
||||
cellCtx = c
|
||||
}
|
||||
}
|
||||
hSpan := 1
|
||||
if cellCtx.Merge.Horizontal.Present {
|
||||
if cellCtx.Merge.Horizontal.Start {
|
||||
hSpan = cellCtx.Merge.Horizontal.Span
|
||||
if hSpan <= 0 {
|
||||
hSpan = 1
|
||||
}
|
||||
} else {
|
||||
currentTableCol++
|
||||
continue
|
||||
}
|
||||
}
|
||||
textPixelWidth := s.estimateTextWidth(cellContent)
|
||||
contentAndPaddingWidth := textPixelWidth + (2 * s.config.Padding)
|
||||
if hSpan == 1 {
|
||||
if currentTableCol < len(s.calculatedColWidths) && contentAndPaddingWidth > s.calculatedColWidths[currentTableCol] {
|
||||
s.calculatedColWidths[currentTableCol] = contentAndPaddingWidth
|
||||
}
|
||||
} else {
|
||||
totalMergedWidth := contentAndPaddingWidth + (float64(hSpan-1) * s.config.Padding * 2)
|
||||
if totalMergedWidth < s.config.MinColWidth*float64(hSpan) {
|
||||
totalMergedWidth = s.config.MinColWidth * float64(hSpan)
|
||||
}
|
||||
if currentTableCol < len(s.calculatedColWidths) {
|
||||
key := mergeKey{currentTableCol, hSpan}
|
||||
if currentWidth, ok := maxMergeWidths[key]; ok {
|
||||
if totalMergedWidth > currentWidth {
|
||||
maxMergeWidths[key] = totalMergedWidth
|
||||
}
|
||||
} else {
|
||||
maxMergeWidths[key] = totalMergedWidth
|
||||
}
|
||||
s.debug("Horizontal merge at col %d, span %d: Total width %.2f", currentTableCol, hSpan, totalMergedWidth)
|
||||
}
|
||||
}
|
||||
currentTableCol += hSpan
|
||||
currentVisualCol++
|
||||
}
|
||||
}
|
||||
}
|
||||
processSectionForWidth(sectionTypeHeader)
|
||||
processSectionForWidth(sectionTypeRow)
|
||||
processSectionForWidth(sectionTypeFooter)
|
||||
|
||||
// Apply maximum widths for merged cells
|
||||
for key, width := range maxMergeWidths {
|
||||
s.calculatedColWidths[key.startCol] = width
|
||||
for i := 1; i < key.span && (key.startCol+i) < len(s.calculatedColWidths); i++ {
|
||||
s.calculatedColWidths[key.startCol+i] = 0
|
||||
}
|
||||
}
|
||||
|
||||
for i := range s.calculatedColWidths {
|
||||
if s.calculatedColWidths[i] < s.config.MinColWidth && s.calculatedColWidths[i] != 0 {
|
||||
s.calculatedColWidths[i] = s.config.MinColWidth
|
||||
}
|
||||
}
|
||||
s.debug("Column widths: %v", s.calculatedColWidths)
|
||||
}
|
||||
|
||||
// Close finalizes SVG rendering and writes output.
|
||||
// Parameter w is the output w.
|
||||
// Returns an error if writing fails.
|
||||
func (s *SVG) Close() error {
|
||||
s.debug("Finalizing SVG output")
|
||||
s.calculateAllColumnWidths()
|
||||
s.renderBufferedData()
|
||||
if s.numVisualRowsDrawn == 0 && s.maxCols == 0 {
|
||||
fmt.Fprintf(s.w, `<svg xmlns="http://www.w3.org/2000/svg" width="%.2f" height="%.2f"></svg>`, s.config.StrokeWidth*2, s.config.StrokeWidth*2)
|
||||
return nil
|
||||
}
|
||||
totalWidth := s.config.StrokeWidth
|
||||
if len(s.calculatedColWidths) > 0 {
|
||||
for _, cw := range s.calculatedColWidths {
|
||||
colWidth := cw
|
||||
if colWidth <= 0 {
|
||||
colWidth = s.config.MinColWidth
|
||||
}
|
||||
totalWidth += colWidth + s.config.StrokeWidth
|
||||
}
|
||||
} else if s.maxCols > 0 {
|
||||
for i := 0; i < s.maxCols; i++ {
|
||||
totalWidth += s.config.MinColWidth + s.config.StrokeWidth
|
||||
}
|
||||
} else {
|
||||
totalWidth = s.config.StrokeWidth * 2
|
||||
}
|
||||
totalHeight := s.currentY
|
||||
singleVisualRowHeight := s.config.FontSize*s.config.LineHeightFactor + (2 * s.config.Padding)
|
||||
if s.numVisualRowsDrawn == 0 {
|
||||
if s.maxCols > 0 {
|
||||
totalHeight = s.config.StrokeWidth + singleVisualRowHeight + s.config.StrokeWidth
|
||||
} else {
|
||||
totalHeight = s.config.StrokeWidth * 2
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(s.w, `<svg xmlns="http://www.w3.org/2000/svg" width="%.2f" height="%.2f" font-family="%s" font-size="%.2f">`,
|
||||
totalWidth, totalHeight, html.EscapeString(s.config.FontFamily), s.config.FontSize)
|
||||
fmt.Fprintln(s.w)
|
||||
fmt.Fprintln(s.w, "<style>text { stroke: none; }</style>")
|
||||
if _, err := io.WriteString(s.w, s.svgElements.String()); err != nil {
|
||||
fmt.Fprintln(s.w, `</svg>`)
|
||||
return fmt.Errorf("failed to write SVG elements: %w", err)
|
||||
}
|
||||
if s.maxCols > 0 || s.numVisualRowsDrawn > 0 {
|
||||
fmt.Fprintf(s.w, ` <g class="table-borders" stroke="%s" stroke-width="%.2f" stroke-linecap="square">`,
|
||||
html.EscapeString(s.config.StrokeColor), s.config.StrokeWidth)
|
||||
fmt.Fprintln(s.w)
|
||||
yPos := s.config.StrokeWidth / 2.0
|
||||
borderRowsToDraw := s.numVisualRowsDrawn
|
||||
if borderRowsToDraw == 0 && s.maxCols > 0 {
|
||||
borderRowsToDraw = 1
|
||||
}
|
||||
lineStartX := s.config.StrokeWidth / 2.0
|
||||
lineEndX := s.config.StrokeWidth / 2.0
|
||||
for _, width := range s.calculatedColWidths {
|
||||
lineEndX += width + s.config.StrokeWidth
|
||||
}
|
||||
for i := 0; i <= borderRowsToDraw; i++ {
|
||||
fmt.Fprintf(s.w, ` <line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" />%s`,
|
||||
lineStartX, yPos, lineEndX, yPos, "\n")
|
||||
if i < borderRowsToDraw {
|
||||
yPos += singleVisualRowHeight + s.config.StrokeWidth
|
||||
}
|
||||
}
|
||||
xPos := s.config.StrokeWidth / 2.0
|
||||
borderLineStartY := s.config.StrokeWidth / 2.0
|
||||
borderLineEndY := totalHeight - (s.config.StrokeWidth / 2.0)
|
||||
for visualColIdx := 0; visualColIdx <= s.maxCols; visualColIdx++ {
|
||||
fmt.Fprintf(s.w, ` <line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" />%s`,
|
||||
xPos, borderLineStartY, xPos, borderLineEndY, "\n")
|
||||
if visualColIdx < s.maxCols {
|
||||
colWidth := s.config.MinColWidth
|
||||
if visualColIdx < len(s.calculatedColWidths) && s.calculatedColWidths[visualColIdx] > 0 {
|
||||
colWidth = s.calculatedColWidths[visualColIdx]
|
||||
}
|
||||
xPos += colWidth + s.config.StrokeWidth
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(s.w, " </g>")
|
||||
}
|
||||
fmt.Fprintln(s.w, `</svg>`)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Config returns the renderer's configuration.
|
||||
// No parameters are required.
|
||||
// Returns a Rendition with border and debug settings.
|
||||
func (s *SVG) Config() tw.Rendition {
|
||||
return tw.Rendition{
|
||||
Borders: tw.Border{Left: tw.On, Right: tw.On, Top: tw.On, Bottom: tw.On},
|
||||
Settings: tw.Settings{},
|
||||
Streaming: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Debug returns the renderer's debug trace.
|
||||
// No parameters are required.
|
||||
// Returns a slice of debug messages.
|
||||
func (s *SVG) Debug() []string {
|
||||
return s.trace
|
||||
}
|
||||
|
||||
// estimateTextWidth estimates text width in SVG units.
|
||||
// Parameter text is the input string to measure.
|
||||
// Returns the estimated width based on font size and char factor.
|
||||
func (s *SVG) estimateTextWidth(text string) float64 {
|
||||
runeCount := float64(len([]rune(text)))
|
||||
return runeCount * s.config.FontSize * s.config.ApproxCharWidthFactor
|
||||
}
|
||||
|
||||
// Footer buffers footer lines for SVG rendering.
|
||||
// Parameters include w (w), footers (lines), and ctx (formatting).
|
||||
// No return value; stores data for later rendering.
|
||||
func (s *SVG) Footer(footers [][]string, ctx tw.Formatting) {
|
||||
s.debug("Buffering %d footer lines", len(footers))
|
||||
for i, line := range footers {
|
||||
currentCtx := ctx
|
||||
currentCtx.IsSubRow = (i > 0)
|
||||
s.storeVisualLine(sectionTypeFooter, line, currentCtx)
|
||||
}
|
||||
}
|
||||
|
||||
// getSVGAnchorFromTW maps tablewriter alignment to SVG text-anchor.
|
||||
// Parameter align is the tablewriter alignment setting.
|
||||
// Returns the corresponding SVG text-anchor value or empty string.
|
||||
func (s *SVG) getSVGAnchorFromTW(align tw.Align) string {
|
||||
switch align {
|
||||
case tw.AlignLeft:
|
||||
return "start"
|
||||
case tw.AlignCenter:
|
||||
return "middle"
|
||||
case tw.AlignRight:
|
||||
return "end"
|
||||
case tw.AlignNone, tw.Skip:
|
||||
return tw.Empty
|
||||
}
|
||||
return tw.Empty
|
||||
}
|
||||
|
||||
// Header buffers header lines for SVG rendering.
|
||||
// Parameters include w (w), headers (lines), and ctx (formatting).
|
||||
// No return value; stores data for later rendering.
|
||||
func (s *SVG) Header(headers [][]string, ctx tw.Formatting) {
|
||||
s.debug("Buffering %d header lines", len(headers))
|
||||
for i, line := range headers {
|
||||
currentCtx := ctx
|
||||
currentCtx.IsSubRow = i > 0
|
||||
s.storeVisualLine(sectionTypeHeader, line, currentCtx)
|
||||
}
|
||||
}
|
||||
|
||||
// Line handles border rendering (ignored in SVG renderer).
|
||||
// Parameters include w (w) and ctx (formatting).
|
||||
// No return value; SVG borders are drawn in Close.
|
||||
func (s *SVG) Line(ctx tw.Formatting) {
|
||||
s.debug("Line rendering ignored")
|
||||
}
|
||||
|
||||
// padLineSVG pads a line to the specified column count.
|
||||
// Parameters include line (input strings) and numCols (target length).
|
||||
// Returns the padded line with empty strings as needed.
|
||||
func padLineSVG(line []string, numCols int) []string {
|
||||
if numCols <= 0 {
|
||||
return []string{}
|
||||
}
|
||||
currentLen := len(line)
|
||||
if currentLen == numCols {
|
||||
return line
|
||||
}
|
||||
if currentLen > numCols {
|
||||
return line[:numCols]
|
||||
}
|
||||
padded := make([]string, numCols)
|
||||
copy(padded, line)
|
||||
return padded
|
||||
}
|
||||
|
||||
// renderBufferedData renders all buffered lines to SVG elements.
|
||||
// No parameters are required.
|
||||
// No return value; populates svgElements buffer.
|
||||
func (s *SVG) renderBufferedData() {
|
||||
s.debug("Rendering buffered data")
|
||||
s.currentY = s.config.StrokeWidth
|
||||
s.dataRowCounter = 0
|
||||
s.vMergeTrack = make(map[int]int)
|
||||
s.numVisualRowsDrawn = 0
|
||||
renderSection := func(sectionIdx int, position tw.Position) {
|
||||
for visualLineIdx, visualLineData := range s.allVisualLineData[sectionIdx] {
|
||||
if visualLineIdx >= len(s.allVisualLineCtx[sectionIdx]) {
|
||||
s.debug("Error: Missing context for section %d line %d", sectionIdx, visualLineIdx)
|
||||
continue
|
||||
}
|
||||
s.renderVisualLine(visualLineData, s.allVisualLineCtx[sectionIdx][visualLineIdx], position)
|
||||
}
|
||||
}
|
||||
renderSection(sectionTypeHeader, tw.Header)
|
||||
renderSection(sectionTypeRow, tw.Row)
|
||||
renderSection(sectionTypeFooter, tw.Footer)
|
||||
}
|
||||
|
||||
// renderVisualLine renders a single visual line as SVG elements.
|
||||
// Parameters include lineData (cell content), ctx (formatting), and position (section type).
|
||||
// No return value; handles horizontal and vertical merges.
|
||||
func (s *SVG) renderVisualLine(visualLineData []string, ctx tw.Formatting, position tw.Position) {
|
||||
if s.maxCols == 0 || len(s.calculatedColWidths) == 0 {
|
||||
s.debug("Skipping line rendering: maxCols=%d, widths=%d", s.maxCols, len(s.calculatedColWidths))
|
||||
return
|
||||
}
|
||||
s.numVisualRowsDrawn++
|
||||
s.debug("Rendering visual row %d", s.numVisualRowsDrawn)
|
||||
singleVisualRowHeight := s.config.FontSize*s.config.LineHeightFactor + (2 * s.config.Padding)
|
||||
bgColor := tw.Empty
|
||||
textColor := tw.Empty
|
||||
defaultTextAnchor := "start"
|
||||
switch position {
|
||||
case tw.Header:
|
||||
bgColor = s.config.HeaderBG
|
||||
textColor = s.config.HeaderColor
|
||||
defaultTextAnchor = "middle"
|
||||
case tw.Footer:
|
||||
bgColor = s.config.FooterBG
|
||||
textColor = s.config.FooterColor
|
||||
defaultTextAnchor = "end"
|
||||
default:
|
||||
textColor = s.config.RowColor
|
||||
if !ctx.IsSubRow {
|
||||
if s.config.RowAltBG != tw.Empty && s.dataRowCounter%2 != 0 {
|
||||
bgColor = s.config.RowAltBG
|
||||
} else {
|
||||
bgColor = s.config.RowBG
|
||||
}
|
||||
s.dataRowCounter++
|
||||
} else {
|
||||
parentDataRowStripeIndex := max(s.dataRowCounter-1, 0)
|
||||
if s.config.RowAltBG != tw.Empty && parentDataRowStripeIndex%2 != 0 {
|
||||
bgColor = s.config.RowAltBG
|
||||
} else {
|
||||
bgColor = s.config.RowBG
|
||||
}
|
||||
}
|
||||
}
|
||||
currentX := s.config.StrokeWidth
|
||||
currentVisualCellIdx := 0
|
||||
for tableColIdx := 0; tableColIdx < s.maxCols; {
|
||||
if tableColIdx >= len(s.calculatedColWidths) {
|
||||
s.debug("Table Col %d out of bounds for widths", tableColIdx)
|
||||
tableColIdx++
|
||||
continue
|
||||
}
|
||||
if remainingVSpan, isMerging := s.vMergeTrack[tableColIdx]; isMerging && remainingVSpan > 1 {
|
||||
s.vMergeTrack[tableColIdx]--
|
||||
if s.vMergeTrack[tableColIdx] <= 1 {
|
||||
delete(s.vMergeTrack, tableColIdx)
|
||||
}
|
||||
currentX += s.calculatedColWidths[tableColIdx] + s.config.StrokeWidth
|
||||
tableColIdx++
|
||||
continue
|
||||
}
|
||||
cellContentFromVisualLine := tw.Empty
|
||||
if currentVisualCellIdx < len(visualLineData) {
|
||||
cellContentFromVisualLine = visualLineData[currentVisualCellIdx]
|
||||
}
|
||||
cellCtx := tw.CellContext{}
|
||||
if ctx.Row.Current != nil {
|
||||
if c, ok := ctx.Row.Current[tableColIdx]; ok {
|
||||
cellCtx = c
|
||||
}
|
||||
}
|
||||
textToRender := cellContentFromVisualLine
|
||||
if cellCtx.Data != tw.Empty {
|
||||
if !((cellCtx.Merge.Vertical.Present && !cellCtx.Merge.Vertical.Start) || (cellCtx.Merge.Hierarchical.Present && !cellCtx.Merge.Hierarchical.Start)) {
|
||||
textToRender = cellCtx.Data
|
||||
} else {
|
||||
textToRender = tw.Empty
|
||||
}
|
||||
} else if (cellCtx.Merge.Vertical.Present && !cellCtx.Merge.Vertical.Start) || (cellCtx.Merge.Hierarchical.Present && !cellCtx.Merge.Hierarchical.Start) {
|
||||
textToRender = tw.Empty
|
||||
}
|
||||
hSpan := 1
|
||||
if cellCtx.Merge.Horizontal.Present {
|
||||
if cellCtx.Merge.Horizontal.Start {
|
||||
hSpan = cellCtx.Merge.Horizontal.Span
|
||||
if hSpan <= 0 {
|
||||
hSpan = 1
|
||||
}
|
||||
} else {
|
||||
currentX += s.calculatedColWidths[tableColIdx] + s.config.StrokeWidth
|
||||
tableColIdx++
|
||||
continue
|
||||
}
|
||||
}
|
||||
vSpan := 1
|
||||
isVSpanStart := false
|
||||
if cellCtx.Merge.Vertical.Present && cellCtx.Merge.Vertical.Start {
|
||||
vSpan = cellCtx.Merge.Vertical.Span
|
||||
isVSpanStart = true
|
||||
} else if cellCtx.Merge.Hierarchical.Present && cellCtx.Merge.Hierarchical.Start {
|
||||
vSpan = cellCtx.Merge.Hierarchical.Span
|
||||
isVSpanStart = true
|
||||
}
|
||||
if vSpan <= 0 {
|
||||
vSpan = 1
|
||||
}
|
||||
rectWidth := 0.0
|
||||
for hs := 0; hs < hSpan && (tableColIdx+hs) < s.maxCols; hs++ {
|
||||
if (tableColIdx + hs) < len(s.calculatedColWidths) {
|
||||
rectWidth += s.calculatedColWidths[tableColIdx+hs]
|
||||
} else {
|
||||
rectWidth += s.config.MinColWidth
|
||||
}
|
||||
}
|
||||
if hSpan > 1 {
|
||||
rectWidth += float64(hSpan-1) * s.config.StrokeWidth
|
||||
}
|
||||
if rectWidth <= 0 {
|
||||
tableColIdx += hSpan
|
||||
if hSpan > 0 {
|
||||
currentVisualCellIdx++
|
||||
}
|
||||
continue
|
||||
}
|
||||
rectHeight := singleVisualRowHeight
|
||||
if isVSpanStart && vSpan > 1 {
|
||||
rectHeight = float64(vSpan)*singleVisualRowHeight + float64(vSpan-1)*s.config.StrokeWidth
|
||||
for hs := 0; hs < hSpan && (tableColIdx+hs) < s.maxCols; hs++ {
|
||||
s.vMergeTrack[tableColIdx+hs] = vSpan
|
||||
}
|
||||
s.debug("Vertical merge at col %d, span %d, height %.2f", tableColIdx, vSpan, rectHeight)
|
||||
} else if remainingVSpan, isMerging := s.vMergeTrack[tableColIdx]; isMerging && remainingVSpan > 1 {
|
||||
rectHeight = singleVisualRowHeight
|
||||
textToRender = tw.Empty
|
||||
}
|
||||
fmt.Fprintf(&s.svgElements, ` <rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s"/>%s`,
|
||||
currentX, s.currentY, rectWidth, rectHeight, html.EscapeString(bgColor), "\n")
|
||||
cellTextAnchor := defaultTextAnchor
|
||||
if s.config.RenderTWConfigOverrides {
|
||||
if al := s.getSVGAnchorFromTW(cellCtx.Align); al != tw.Empty {
|
||||
cellTextAnchor = al
|
||||
}
|
||||
}
|
||||
textX := currentX + s.config.Padding
|
||||
switch cellTextAnchor {
|
||||
case "middle":
|
||||
textX = currentX + s.config.Padding + (rectWidth-2*s.config.Padding)/2.0
|
||||
case "end":
|
||||
textX = currentX + rectWidth - s.config.Padding
|
||||
}
|
||||
textY := s.currentY + rectHeight/2.0
|
||||
escapedCell := html.EscapeString(textToRender)
|
||||
fmt.Fprintf(&s.svgElements, ` <text x="%.2f" y="%.2f" fill="%s" text-anchor="%s" dominant-baseline="middle">%s</text>%s`,
|
||||
textX, textY, html.EscapeString(textColor), cellTextAnchor, escapedCell, "\n")
|
||||
currentX += rectWidth + s.config.StrokeWidth
|
||||
tableColIdx += hSpan
|
||||
currentVisualCellIdx++
|
||||
}
|
||||
s.currentY += singleVisualRowHeight + s.config.StrokeWidth
|
||||
}
|
||||
|
||||
// Reset clears the renderer's internal state.
|
||||
// No parameters are required.
|
||||
// No return value; prepares for new rendering.
|
||||
func (s *SVG) Reset() {
|
||||
s.debug("Resetting state")
|
||||
s.trace = make([]string, 0, 50)
|
||||
for i := 0; i < 3; i++ {
|
||||
s.allVisualLineData[i] = s.allVisualLineData[i][:0]
|
||||
s.allVisualLineCtx[i] = s.allVisualLineCtx[i][:0]
|
||||
}
|
||||
s.maxCols = 0
|
||||
s.calculatedColWidths = nil
|
||||
s.svgElements.Reset()
|
||||
s.currentY = 0
|
||||
s.dataRowCounter = 0
|
||||
s.vMergeTrack = make(map[int]int)
|
||||
s.numVisualRowsDrawn = 0
|
||||
}
|
||||
|
||||
// Row buffers a row line for SVG rendering.
|
||||
// Parameters include w (w), rowLine (cells), and ctx (formatting).
|
||||
// No return value; stores data for later rendering.
|
||||
func (s *SVG) Row(rowLine []string, ctx tw.Formatting) {
|
||||
s.debug("Buffering row line, IsSubRow: %v", ctx.IsSubRow)
|
||||
s.storeVisualLine(sectionTypeRow, rowLine, ctx)
|
||||
}
|
||||
|
||||
func (s *SVG) Logger(logger *ll.Logger) {
|
||||
s.logger = logger.Namespace("svg")
|
||||
}
|
||||
|
||||
// Start initializes SVG rendering.
|
||||
// Parameter w is the output w.
|
||||
// Returns nil; prepares internal state.
|
||||
func (s *SVG) Start(w io.Writer) error {
|
||||
s.w = w
|
||||
s.debug("Starting SVG rendering")
|
||||
s.Reset()
|
||||
return nil
|
||||
}
|
||||
|
||||
// debug logs a message if debugging is enabled.
|
||||
// Parameters include format string and variadic arguments.
|
||||
// No return value; appends to trace.
|
||||
func (s *SVG) debug(format string, a ...interface{}) {
|
||||
if s.config.Debug {
|
||||
msg := fmt.Sprintf(format, a...)
|
||||
s.trace = append(s.trace, "[SVG] "+msg)
|
||||
}
|
||||
}
|
||||
|
||||
// storeVisualLine stores a visual line for rendering.
|
||||
// Parameters include sectionIdx, lineData (cells), and ctx (formatting).
|
||||
// No return value; buffers data and context.
|
||||
func (s *SVG) storeVisualLine(sectionIdx int, lineData []string, ctx tw.Formatting) {
|
||||
copiedLineData := make([]string, len(lineData))
|
||||
copy(copiedLineData, lineData)
|
||||
s.allVisualLineData[sectionIdx] = append(s.allVisualLineData[sectionIdx], copiedLineData)
|
||||
s.allVisualLineCtx[sectionIdx] = append(s.allVisualLineCtx[sectionIdx], ctx)
|
||||
hasCurrent := ctx.Row.Current != nil
|
||||
s.debug("Stored line in section %d, has context: %v", sectionIdx, hasCurrent)
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package renderer
|
||||
|
||||
import "github.com/fatih/color"
|
||||
|
||||
// Colors is a slice of color attributes for use with fatih/color, such as color.FgWhite or color.Bold.
|
||||
type Colors []color.Attribute
|
||||
|
||||
// Tint defines foreground and background color settings for table elements, with optional per-column overrides.
|
||||
type Tint struct {
|
||||
FG Colors // Foreground color attributes
|
||||
BG Colors // Background color attributes
|
||||
Columns []Tint // Per-column color settings
|
||||
}
|
||||
|
||||
// Apply applies the Tint's foreground and background colors to the given text, returning the text unchanged if no colors are set.
|
||||
func (t Tint) Apply(text string) string {
|
||||
if len(t.FG) == 0 && len(t.BG) == 0 {
|
||||
return text
|
||||
}
|
||||
// Combine foreground and background colors
|
||||
combinedColors := append(t.FG, t.BG...)
|
||||
// Create a color function and apply it to the text
|
||||
c := color.New(combinedColors...).SprintFunc()
|
||||
return c(text)
|
||||
}
|
||||
+1164
File diff suppressed because it is too large
Load Diff
+2333
File diff suppressed because it is too large
Load Diff
+79
@@ -0,0 +1,79 @@
|
||||
package tw
|
||||
|
||||
// CellFormatting holds formatting options for table cells.
|
||||
type CellFormatting struct {
|
||||
AutoWrap int // Wrapping behavior (e.g., WrapTruncate, WrapNormal)
|
||||
AutoFormat State // Enables automatic formatting (e.g., title case for headers)
|
||||
|
||||
// Deprecated: Kept for backward compatibility. Use CellConfig.CellMerging.Mode instead.
|
||||
// This will be removed in a future version.
|
||||
MergeMode int
|
||||
|
||||
// Deprecated: Kept for backward compatibility. Use CellConfig.Alignment instead.
|
||||
// This will be removed in a future version.
|
||||
Alignment Align
|
||||
}
|
||||
|
||||
// CellMerging holds the configuration for how cells should be merged.
|
||||
// This new struct replaces the deprecated MergeMode.
|
||||
type CellMerging struct {
|
||||
// Mode is a bitmask specifying the type of merge (e.g., MergeHorizontal, MergeVertical).
|
||||
Mode int
|
||||
|
||||
// ByColumnIndex specifies which column indices should be considered for merging.
|
||||
// If the mapper is nil or empty, merging applies to all columns (if Mode is set).
|
||||
// Otherwise, only columns with an index present as a key will be merged.
|
||||
ByColumnIndex Mapper[int, bool]
|
||||
|
||||
// ByRowIndex is reserved for future features to specify merging on specific rows.
|
||||
ByRowIndex Mapper[int, bool]
|
||||
}
|
||||
|
||||
// CellPadding defines padding settings for table cells.
|
||||
type CellPadding struct {
|
||||
Global Padding // Default padding applied to all cells
|
||||
PerColumn []Padding // Column-specific padding overrides
|
||||
}
|
||||
|
||||
// CellFilter defines filtering functions for cell content.
|
||||
type CellFilter struct {
|
||||
Global func([]string) []string // Processes the entire row
|
||||
PerColumn []func(string) string // Processes individual cells by column
|
||||
}
|
||||
|
||||
// CellCallbacks holds callback functions for cell processing.
|
||||
// Note: These are currently placeholders and not fully implemented.
|
||||
type CellCallbacks struct {
|
||||
Global func() // Global callback applied to all cells
|
||||
PerColumn []func() // Column-specific callbacks
|
||||
}
|
||||
|
||||
// CellAlignment defines alignment settings for table cells.
|
||||
type CellAlignment struct {
|
||||
Global Align // Default alignment applied to all cells
|
||||
PerColumn []Align // Column-specific alignment overrides
|
||||
}
|
||||
|
||||
// CellConfig combines formatting, padding, and callback settings for a table section.
|
||||
type CellConfig struct {
|
||||
Formatting CellFormatting // Cell formatting options
|
||||
Padding CellPadding // Padding configuration
|
||||
Callbacks CellCallbacks // Callback functions (unused)
|
||||
Filter CellFilter // Function to filter cell content (renamed from Filter Filter)
|
||||
Alignment CellAlignment // Alignment configuration for cells
|
||||
ColMaxWidths CellWidth // Per-column maximum width overrides
|
||||
Merging CellMerging // Merging holds all configuration related to cell merging.
|
||||
|
||||
// Deprecated: use Alignment.PerColumn instead. Will be removed in a future version.
|
||||
// will be removed soon
|
||||
ColumnAligns []Align // Per-column alignment overrides
|
||||
}
|
||||
|
||||
type CellWidth struct {
|
||||
Global int
|
||||
PerColumn Mapper[int, int]
|
||||
}
|
||||
|
||||
func (c CellWidth) Constrained() bool {
|
||||
return c.Global > 0 || c.PerColumn.Len() > 0
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
package tw
|
||||
|
||||
// Deprecated: SymbolASCII is deprecated; use Glyphs with StyleASCII instead.
|
||||
// this will be removed soon
|
||||
type SymbolASCII struct{}
|
||||
|
||||
// SymbolASCII symbol methods
|
||||
func (s *SymbolASCII) Name() string { return StyleNameASCII.String() }
|
||||
func (s *SymbolASCII) Center() string { return "+" }
|
||||
func (s *SymbolASCII) Row() string { return "-" }
|
||||
func (s *SymbolASCII) Column() string { return "|" }
|
||||
func (s *SymbolASCII) TopLeft() string { return "+" }
|
||||
func (s *SymbolASCII) TopMid() string { return "+" }
|
||||
func (s *SymbolASCII) TopRight() string { return "+" }
|
||||
func (s *SymbolASCII) MidLeft() string { return "+" }
|
||||
func (s *SymbolASCII) MidRight() string { return "+" }
|
||||
func (s *SymbolASCII) BottomLeft() string { return "+" }
|
||||
func (s *SymbolASCII) BottomMid() string { return "+" }
|
||||
func (s *SymbolASCII) BottomRight() string { return "+" }
|
||||
func (s *SymbolASCII) HeaderLeft() string { return "+" }
|
||||
func (s *SymbolASCII) HeaderMid() string { return "+" }
|
||||
func (s *SymbolASCII) HeaderRight() string { return "+" }
|
||||
|
||||
// Deprecated: SymbolUnicode is deprecated; use Glyphs with appropriate styles (e.g., StyleLight, StyleHeavy) instead.
|
||||
// this will be removed soon
|
||||
type SymbolUnicode struct {
|
||||
row string
|
||||
column string
|
||||
center string
|
||||
corners [9]string // [topLeft, topMid, topRight, midLeft, center, midRight, bottomLeft, bottomMid, bottomRight]
|
||||
}
|
||||
|
||||
// SymbolUnicode symbol methods
|
||||
func (s *SymbolUnicode) Name() string { return "unicode" }
|
||||
func (s *SymbolUnicode) Center() string { return s.center }
|
||||
func (s *SymbolUnicode) Row() string { return s.row }
|
||||
func (s *SymbolUnicode) Column() string { return s.column }
|
||||
func (s *SymbolUnicode) TopLeft() string { return s.corners[0] }
|
||||
func (s *SymbolUnicode) TopMid() string { return s.corners[1] }
|
||||
func (s *SymbolUnicode) TopRight() string { return s.corners[2] }
|
||||
func (s *SymbolUnicode) MidLeft() string { return s.corners[3] }
|
||||
func (s *SymbolUnicode) MidRight() string { return s.corners[5] }
|
||||
func (s *SymbolUnicode) BottomLeft() string { return s.corners[6] }
|
||||
func (s *SymbolUnicode) BottomMid() string { return s.corners[7] }
|
||||
func (s *SymbolUnicode) BottomRight() string { return s.corners[8] }
|
||||
func (s *SymbolUnicode) HeaderLeft() string { return s.MidLeft() }
|
||||
func (s *SymbolUnicode) HeaderMid() string { return s.Center() }
|
||||
func (s *SymbolUnicode) HeaderRight() string { return s.MidRight() }
|
||||
|
||||
// Deprecated: SymbolMarkdown is deprecated; use Glyphs with StyleMarkdown instead.
|
||||
// this will be removed soon
|
||||
type SymbolMarkdown struct{}
|
||||
|
||||
// SymbolMarkdown symbol methods
|
||||
func (s *SymbolMarkdown) Name() string { return StyleNameMarkdown.String() }
|
||||
func (s *SymbolMarkdown) Center() string { return "|" }
|
||||
func (s *SymbolMarkdown) Row() string { return "-" }
|
||||
func (s *SymbolMarkdown) Column() string { return "|" }
|
||||
func (s *SymbolMarkdown) TopLeft() string { return "" }
|
||||
func (s *SymbolMarkdown) TopMid() string { return "" }
|
||||
func (s *SymbolMarkdown) TopRight() string { return "" }
|
||||
func (s *SymbolMarkdown) MidLeft() string { return "|" }
|
||||
func (s *SymbolMarkdown) MidRight() string { return "|" }
|
||||
func (s *SymbolMarkdown) BottomLeft() string { return "" }
|
||||
func (s *SymbolMarkdown) BottomMid() string { return "" }
|
||||
func (s *SymbolMarkdown) BottomRight() string { return "" }
|
||||
func (s *SymbolMarkdown) HeaderLeft() string { return "|" }
|
||||
func (s *SymbolMarkdown) HeaderMid() string { return "|" }
|
||||
func (s *SymbolMarkdown) HeaderRight() string { return "|" }
|
||||
|
||||
// Deprecated: SymbolNothing is deprecated; use Glyphs with StyleNone instead.
|
||||
// this will be removed soon
|
||||
type SymbolNothing struct{}
|
||||
|
||||
// SymbolNothing symbol methods
|
||||
func (s *SymbolNothing) Name() string { return StyleNameNothing.String() }
|
||||
func (s *SymbolNothing) Center() string { return "" }
|
||||
func (s *SymbolNothing) Row() string { return "" }
|
||||
func (s *SymbolNothing) Column() string { return "" }
|
||||
func (s *SymbolNothing) TopLeft() string { return "" }
|
||||
func (s *SymbolNothing) TopMid() string { return "" }
|
||||
func (s *SymbolNothing) TopRight() string { return "" }
|
||||
func (s *SymbolNothing) MidLeft() string { return "" }
|
||||
func (s *SymbolNothing) MidRight() string { return "" }
|
||||
func (s *SymbolNothing) BottomLeft() string { return "" }
|
||||
func (s *SymbolNothing) BottomMid() string { return "" }
|
||||
func (s *SymbolNothing) BottomRight() string { return "" }
|
||||
func (s *SymbolNothing) HeaderLeft() string { return "" }
|
||||
func (s *SymbolNothing) HeaderMid() string { return "" }
|
||||
func (s *SymbolNothing) HeaderRight() string { return "" }
|
||||
|
||||
// Deprecated: SymbolGraphical is deprecated; use Glyphs with StyleGraphical instead.
|
||||
// this will be removed soon
|
||||
type SymbolGraphical struct{}
|
||||
|
||||
// SymbolGraphical symbol methods
|
||||
func (s *SymbolGraphical) Name() string { return StyleNameGraphical.String() }
|
||||
func (s *SymbolGraphical) Center() string { return "🟧" } // Orange square (matches mid junctions)
|
||||
func (s *SymbolGraphical) Row() string { return "🟥" } // Red square (matches corners)
|
||||
func (s *SymbolGraphical) Column() string { return "🟦" } // Blue square (vertical line)
|
||||
func (s *SymbolGraphical) TopLeft() string { return "🟥" } // Top-left corner
|
||||
func (s *SymbolGraphical) TopMid() string { return "🔳" } // Top junction
|
||||
func (s *SymbolGraphical) TopRight() string { return "🟥" } // Top-right corner
|
||||
func (s *SymbolGraphical) MidLeft() string { return "🟧" } // Left junction
|
||||
func (s *SymbolGraphical) MidRight() string { return "🟧" } // Right junction
|
||||
func (s *SymbolGraphical) BottomLeft() string { return "🟥" } // Bottom-left corner
|
||||
func (s *SymbolGraphical) BottomMid() string { return "🔳" } // Bottom junction
|
||||
func (s *SymbolGraphical) BottomRight() string { return "🟥" } // Bottom-right corner
|
||||
func (s *SymbolGraphical) HeaderLeft() string { return "🟧" } // Header left (matches mid junctions)
|
||||
func (s *SymbolGraphical) HeaderMid() string { return "🟧" } // Header middle (matches mid junctions)
|
||||
func (s *SymbolGraphical) HeaderRight() string { return "🟧" } // Header right (matches mid junctions)
|
||||
|
||||
// Deprecated: SymbolMerger is deprecated; use Glyphs with StyleMerger instead.
|
||||
// this will be removed soon
|
||||
type SymbolMerger struct {
|
||||
row string
|
||||
column string
|
||||
center string
|
||||
corners [9]string // [TL, TM, TR, ML, CenterIdx(unused), MR, BL, BM, BR]
|
||||
}
|
||||
|
||||
// SymbolMerger symbol methods
|
||||
func (s *SymbolMerger) Name() string { return StyleNameMerger.String() }
|
||||
func (s *SymbolMerger) Center() string { return s.center } // Main crossing symbol
|
||||
func (s *SymbolMerger) Row() string { return s.row }
|
||||
func (s *SymbolMerger) Column() string { return s.column }
|
||||
func (s *SymbolMerger) TopLeft() string { return s.corners[0] }
|
||||
func (s *SymbolMerger) TopMid() string { return s.corners[1] } // LevelHeader junction
|
||||
func (s *SymbolMerger) TopRight() string { return s.corners[2] }
|
||||
func (s *SymbolMerger) MidLeft() string { return s.corners[3] } // Left junction
|
||||
func (s *SymbolMerger) MidRight() string { return s.corners[5] } // Right junction
|
||||
func (s *SymbolMerger) BottomLeft() string { return s.corners[6] }
|
||||
func (s *SymbolMerger) BottomMid() string { return s.corners[7] } // LevelFooter junction
|
||||
func (s *SymbolMerger) BottomRight() string { return s.corners[8] }
|
||||
func (s *SymbolMerger) HeaderLeft() string { return s.MidLeft() }
|
||||
func (s *SymbolMerger) HeaderMid() string { return s.Center() }
|
||||
func (s *SymbolMerger) HeaderRight() string { return s.MidRight() }
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
// Package tw provides utility functions for text formatting, width calculation, and string manipulation
|
||||
// specifically tailored for table rendering, including handling ANSI escape codes and Unicode text.
|
||||
package tw
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/olekukonko/tablewriter/pkg/twwidth"
|
||||
// For mathematical operations like ceiling
|
||||
// For string-to-number conversions
|
||||
// For string manipulation utilities
|
||||
// For Unicode character classification
|
||||
// For UTF-8 rune handling
|
||||
)
|
||||
|
||||
// Title normalizes and uppercases a label string for use in headers.
|
||||
// It replaces underscores and certain dots with spaces and trims whitespace.
|
||||
func Title(name string) string {
|
||||
origLen := len(name)
|
||||
rs := []rune(name)
|
||||
for i, r := range rs {
|
||||
switch r {
|
||||
case '_':
|
||||
rs[i] = ' ' // Replace underscores with spaces
|
||||
case '.':
|
||||
// Replace dots with spaces unless they are between numeric or space characters
|
||||
if (i != 0 && !IsIsNumericOrSpace(rs[i-1])) || (i != len(rs)-1 && !IsIsNumericOrSpace(rs[i+1])) {
|
||||
rs[i] = ' '
|
||||
}
|
||||
}
|
||||
}
|
||||
name = string(rs)
|
||||
name = strings.TrimSpace(name)
|
||||
// If the input was non-empty but trimmed to empty, return a single space
|
||||
if len(name) == 0 && origLen > 0 {
|
||||
name = " "
|
||||
}
|
||||
// Convert to uppercase for header formatting
|
||||
return strings.ToUpper(name)
|
||||
}
|
||||
|
||||
// PadCenter centers a string within a specified width using a padding character.
|
||||
// Extra padding is split between left and right, with slight preference to left if uneven.
|
||||
func PadCenter(s, pad string, width int) string {
|
||||
gap := width - twwidth.Width(s)
|
||||
if gap > 0 {
|
||||
// Calculate left and right padding; ceil ensures left gets extra if gap is odd
|
||||
gapLeft := int(math.Ceil(float64(gap) / 2))
|
||||
gapRight := gap - gapLeft
|
||||
return strings.Repeat(pad, gapLeft) + s + strings.Repeat(pad, gapRight)
|
||||
}
|
||||
// If no padding needed or string is too wide, return as is
|
||||
return s
|
||||
}
|
||||
|
||||
// PadRight left-aligns a string within a specified width, filling remaining space on the right with padding.
|
||||
func PadRight(s, pad string, width int) string {
|
||||
gap := width - twwidth.Width(s)
|
||||
if gap > 0 {
|
||||
// Append padding to the right
|
||||
return s + strings.Repeat(pad, gap)
|
||||
}
|
||||
// If no padding needed or string is too wide, return as is
|
||||
return s
|
||||
}
|
||||
|
||||
// PadLeft right-aligns a string within a specified width, filling remaining space on the left with padding.
|
||||
func PadLeft(s, pad string, width int) string {
|
||||
gap := width - twwidth.Width(s)
|
||||
if gap > 0 {
|
||||
// Prepend padding to the left
|
||||
return strings.Repeat(pad, gap) + s
|
||||
}
|
||||
// If no padding needed or string is too wide, return as is
|
||||
return s
|
||||
}
|
||||
|
||||
// Pad aligns a string within a specified width using a padding character.
|
||||
// It truncates if the string is wider than the target width.
|
||||
func Pad(s, padChar string, totalWidth int, alignment Align) string {
|
||||
sDisplayWidth := twwidth.Width(s)
|
||||
if sDisplayWidth > totalWidth {
|
||||
return twwidth.Truncate(s, totalWidth) // Only truncate if necessary
|
||||
}
|
||||
switch alignment {
|
||||
case AlignLeft:
|
||||
return PadRight(s, padChar, totalWidth)
|
||||
case AlignRight:
|
||||
return PadLeft(s, padChar, totalWidth)
|
||||
case AlignCenter:
|
||||
return PadCenter(s, padChar, totalWidth)
|
||||
default:
|
||||
return PadRight(s, padChar, totalWidth)
|
||||
}
|
||||
}
|
||||
|
||||
// IsIsNumericOrSpace checks if a rune is a digit or space character.
|
||||
// Used in formatting logic to determine safe character replacements.
|
||||
func IsIsNumericOrSpace(r rune) bool {
|
||||
return ('0' <= r && r <= '9') || r == ' '
|
||||
}
|
||||
|
||||
// IsNumeric checks if a string represents a valid integer or floating-point number.
|
||||
func IsNumeric(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
// Try parsing as integer first
|
||||
if _, err := strconv.Atoi(s); err == nil {
|
||||
return true
|
||||
}
|
||||
// Then try parsing as float
|
||||
_, err := strconv.ParseFloat(s, 64)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// SplitCamelCase splits a camelCase or PascalCase or snake_case string into separate words.
|
||||
// It detects transitions between uppercase, lowercase, digits, and other characters.
|
||||
func SplitCamelCase(src string) (entries []string) {
|
||||
// Validate UTF-8 input; return as single entry if invalid
|
||||
if !utf8.ValidString(src) {
|
||||
return []string{src}
|
||||
}
|
||||
entries = []string{}
|
||||
var runes [][]rune
|
||||
lastClass := 0
|
||||
class := 0
|
||||
// Classify each rune into categories: lowercase (1), uppercase (2), digit (3), other (4)
|
||||
for _, r := range src {
|
||||
switch {
|
||||
case unicode.IsLower(r):
|
||||
class = 1
|
||||
case unicode.IsUpper(r):
|
||||
class = 2
|
||||
case unicode.IsDigit(r):
|
||||
class = 3
|
||||
default:
|
||||
class = 4
|
||||
}
|
||||
// Group consecutive runes of the same class together
|
||||
if class == lastClass {
|
||||
runes[len(runes)-1] = append(runes[len(runes)-1], r)
|
||||
} else {
|
||||
runes = append(runes, []rune{r})
|
||||
}
|
||||
lastClass = class
|
||||
}
|
||||
// Adjust for cases where an uppercase letter is followed by lowercase (e.g., CamelCase)
|
||||
for i := 0; i < len(runes)-1; i++ {
|
||||
if unicode.IsUpper(runes[i][0]) && unicode.IsLower(runes[i+1][0]) {
|
||||
// Move the last uppercase rune to the next group for proper word splitting
|
||||
runes[i+1] = append([]rune{runes[i][len(runes[i])-1]}, runes[i+1]...)
|
||||
runes[i] = runes[i][:len(runes[i])-1]
|
||||
}
|
||||
}
|
||||
// Convert rune groups to strings, excluding empty, underscore or whitespace-only groups
|
||||
for _, s := range runes {
|
||||
str := string(s)
|
||||
if len(s) > 0 && strings.TrimSpace(str) != "" && str != "_" {
|
||||
entries = append(entries, str)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Or provides a ternary-like operation for strings, returning 'valid' if cond is true, else 'inValid'.
|
||||
func Or(cond bool, valid, inValid string) string {
|
||||
if cond {
|
||||
return valid
|
||||
}
|
||||
return inValid
|
||||
}
|
||||
|
||||
// Max returns the greater of two integers.
|
||||
func Max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Min returns the smaller of two integers.
|
||||
func Min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// BreakPoint finds the rune index where the display width of a string first exceeds the specified limit.
|
||||
// It returns the number of runes if the entire string fits, or 0 if nothing fits.
|
||||
func BreakPoint(s string, limit int) int {
|
||||
// If limit is 0 or negative, nothing can fit
|
||||
if limit <= 0 {
|
||||
return 0
|
||||
}
|
||||
// Empty string has a breakpoint of 0
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
currentWidth := 0
|
||||
runeCount := 0
|
||||
// Iterate over runes, accumulating display width
|
||||
for _, r := range s {
|
||||
runeWidth := twwidth.Width(string(r)) // Calculate width of individual rune
|
||||
if currentWidth+runeWidth > limit {
|
||||
// Adding this rune would exceed the limit; breakpoint is before this rune
|
||||
if currentWidth == 0 {
|
||||
// First rune is too wide; allow breaking after it if limit > 0
|
||||
if runeWidth > limit && limit > 0 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
return runeCount
|
||||
}
|
||||
currentWidth += runeWidth
|
||||
runeCount++
|
||||
}
|
||||
|
||||
// Entire string fits within the limit
|
||||
return runeCount
|
||||
}
|
||||
|
||||
func MakeAlign(l int, align Align) Alignment {
|
||||
aa := make(Alignment, l)
|
||||
for i := 0; i < l; i++ {
|
||||
aa[i] = align
|
||||
}
|
||||
return aa
|
||||
}
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
package tw
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// KeyValuePair represents a single key-value pair from a Mapper.
|
||||
type KeyValuePair[K comparable, V any] struct {
|
||||
Key K
|
||||
Value V
|
||||
}
|
||||
|
||||
// Mapper is a generic map type with comparable keys and any value type.
|
||||
// It provides type-safe operations on maps with additional convenience methods.
|
||||
type Mapper[K comparable, V any] map[K]V
|
||||
|
||||
// NewMapper creates and returns a new initialized Mapper.
|
||||
func NewMapper[K comparable, V any]() Mapper[K, V] {
|
||||
return make(Mapper[K, V])
|
||||
}
|
||||
|
||||
// Get returns the value associated with the key.
|
||||
// If the key doesn't exist or the map is nil, it returns the zero value for the value type.
|
||||
func (m Mapper[K, V]) Get(key K) V {
|
||||
if m == nil {
|
||||
var zero V
|
||||
return zero
|
||||
}
|
||||
return m[key]
|
||||
}
|
||||
|
||||
// OK returns the value associated with the key and a boolean indicating whether the key exists.
|
||||
func (m Mapper[K, V]) OK(key K) (V, bool) {
|
||||
if m == nil {
|
||||
var zero V
|
||||
return zero, false
|
||||
}
|
||||
val, ok := m[key]
|
||||
return val, ok
|
||||
}
|
||||
|
||||
// Set sets the value for the specified key.
|
||||
// Does nothing if the map is nil.
|
||||
func (m Mapper[K, V]) Set(key K, value V) Mapper[K, V] {
|
||||
if m != nil {
|
||||
m[key] = value
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Delete removes the specified key from the map.
|
||||
// Does nothing if the key doesn't exist or the map is nil.
|
||||
func (m Mapper[K, V]) Delete(key K) Mapper[K, V] {
|
||||
if m != nil {
|
||||
delete(m, key)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Has returns true if the key exists in the map, false otherwise.
|
||||
func (m Mapper[K, V]) Has(key K) bool {
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
_, exists := m[key]
|
||||
return exists
|
||||
}
|
||||
|
||||
// Len returns the number of elements in the map.
|
||||
// Returns 0 if the map is nil.
|
||||
func (m Mapper[K, V]) Len() int {
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
return len(m)
|
||||
}
|
||||
|
||||
// Keys returns a slice containing all keys in the map.
|
||||
// Returns nil if the map is nil or empty.
|
||||
func (m Mapper[K, V]) Keys() []K {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
keys := make([]K, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func (m Mapper[K, V]) Clear() {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
for k := range m {
|
||||
delete(m, k)
|
||||
}
|
||||
}
|
||||
|
||||
// Values returns a slice containing all values in the map.
|
||||
// Returns nil if the map is nil or empty.
|
||||
func (m Mapper[K, V]) Values() []V {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
values := make([]V, 0, len(m))
|
||||
for _, v := range m {
|
||||
values = append(values, v)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
// Each iterates over each key-value pair in the map and calls the provided function.
|
||||
// Does nothing if the map is nil.
|
||||
func (m Mapper[K, V]) Each(fn func(K, V)) {
|
||||
for k, v := range m {
|
||||
fn(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter returns a new Mapper containing only the key-value pairs that satisfy the predicate.
|
||||
func (m Mapper[K, V]) Filter(fn func(K, V) bool) Mapper[K, V] {
|
||||
result := NewMapper[K, V]()
|
||||
for k, v := range m {
|
||||
if fn(k, v) {
|
||||
result[k] = v
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MapValues returns a new Mapper with the same keys but values transformed by the provided function.
|
||||
func (m Mapper[K, V]) MapValues(fn func(V) V) Mapper[K, V] {
|
||||
result := NewMapper[K, V]()
|
||||
for k, v := range m {
|
||||
result[k] = fn(v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Clone returns a shallow copy of the Mapper.
|
||||
func (m Mapper[K, V]) Clone() Mapper[K, V] {
|
||||
result := NewMapper[K, V]()
|
||||
for k, v := range m {
|
||||
result[k] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Slicer converts the Mapper to a Slicer of key-value pairs.
|
||||
func (m Mapper[K, V]) Slicer() Slicer[KeyValuePair[K, V]] {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
result := make(Slicer[KeyValuePair[K, V]], 0, len(m))
|
||||
for k, v := range m {
|
||||
result = append(result, KeyValuePair[K, V]{Key: k, Value: v})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (m Mapper[K, V]) SortedKeys() []K {
|
||||
keys := make([]K, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
a, b := any(keys[i]), any(keys[j])
|
||||
|
||||
switch va := a.(type) {
|
||||
case int:
|
||||
if vb, ok := b.(int); ok {
|
||||
return va < vb
|
||||
}
|
||||
case int32:
|
||||
if vb, ok := b.(int32); ok {
|
||||
return va < vb
|
||||
}
|
||||
case int64:
|
||||
if vb, ok := b.(int64); ok {
|
||||
return va < vb
|
||||
}
|
||||
case uint:
|
||||
if vb, ok := b.(uint); ok {
|
||||
return va < vb
|
||||
}
|
||||
case uint64:
|
||||
if vb, ok := b.(uint64); ok {
|
||||
return va < vb
|
||||
}
|
||||
case float32:
|
||||
if vb, ok := b.(float32); ok {
|
||||
return va < vb
|
||||
}
|
||||
case float64:
|
||||
if vb, ok := b.(float64); ok {
|
||||
return va < vb
|
||||
}
|
||||
case string:
|
||||
if vb, ok := b.(string); ok {
|
||||
return va < vb
|
||||
}
|
||||
}
|
||||
|
||||
// fallback to string comparison
|
||||
return fmt.Sprintf("%v", a) < fmt.Sprintf("%v", b)
|
||||
})
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
func NewBoolMapper[K comparable](keys ...K) Mapper[K, bool] {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
mapper := NewMapper[K, bool]()
|
||||
for _, key := range keys {
|
||||
mapper.Set(key, true)
|
||||
}
|
||||
return mapper
|
||||
}
|
||||
|
||||
func NewIntMapper[K comparable](keys ...K) Mapper[K, int] {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
mapper := NewMapper[K, int]()
|
||||
for _, key := range keys {
|
||||
mapper.Set(key, 0)
|
||||
}
|
||||
return mapper
|
||||
}
|
||||
|
||||
func NewIdentityMapper[K comparable](keys ...K) Mapper[K, K] {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
mapper := NewMapper[K, K]()
|
||||
for _, key := range keys {
|
||||
mapper.Set(key, key)
|
||||
}
|
||||
return mapper
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package tw
|
||||
|
||||
// BorderNone defines a border configuration with all sides disabled.
|
||||
var (
|
||||
// PaddingNone represents explicitly empty padding (no spacing on any side)
|
||||
// Equivalent to Padding{Overwrite: true}
|
||||
PaddingNone = Padding{Left: Empty, Right: Empty, Top: Empty, Bottom: Empty, Overwrite: true}
|
||||
BorderNone = Border{Left: Off, Right: Off, Top: Off, Bottom: Off}
|
||||
LinesNone = Lines{ShowTop: Off, ShowBottom: Off, ShowHeaderLine: Off, ShowFooterLine: Off}
|
||||
SeparatorsNone = Separators{ShowHeader: Off, ShowFooter: Off, BetweenRows: Off, BetweenColumns: Off}
|
||||
)
|
||||
|
||||
// PaddingDefault represents standard single-space padding on left/right
|
||||
// Equivalent to Padding{Left: " ", Right: " ", Overwrite: true}
|
||||
var PaddingDefault = Padding{Left: " ", Right: " ", Overwrite: true}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
package tw
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/olekukonko/ll"
|
||||
)
|
||||
|
||||
// Renderer defines the interface for rendering tables to an io.Writer.
|
||||
// Implementations must handle headers, rows, footers, and separator lines.
|
||||
type Renderer interface {
|
||||
Start(w io.Writer) error
|
||||
Header(headers [][]string, ctx Formatting) // Renders table header
|
||||
Row(row []string, ctx Formatting) // Renders a single row
|
||||
Footer(footers [][]string, ctx Formatting) // Renders table footer
|
||||
Line(ctx Formatting) // Renders separator line
|
||||
Config() Rendition // Returns renderer config
|
||||
Close() error // Gets Rendition form Blueprint
|
||||
Logger(logger *ll.Logger) // send logger to renderers
|
||||
}
|
||||
|
||||
// Rendition holds the configuration for the default renderer.
|
||||
type Rendition struct {
|
||||
Borders Border // Border visibility settings
|
||||
Symbols Symbols // Symbols used for table drawing
|
||||
Settings Settings // Rendering behavior settings
|
||||
Streaming bool
|
||||
}
|
||||
|
||||
// Renditioning has a method to update its rendition.
|
||||
// Let's define an optional interface for this.
|
||||
type Renditioning interface {
|
||||
Rendition(r Rendition)
|
||||
}
|
||||
|
||||
// Formatting encapsulates the complete formatting context for a table row.
|
||||
// It provides all necessary information to render a row correctly within the table structure.
|
||||
type Formatting struct {
|
||||
Row RowContext // Detailed configuration for the row and its cells
|
||||
Level Level // Hierarchical level (Header, Body, Footer) affecting line drawing
|
||||
HasFooter bool // Indicates if the table includes a footer section
|
||||
IsSubRow bool // Marks this as a continuation or padding line in multi-line rows
|
||||
NormalizedWidths Mapper[int, int]
|
||||
}
|
||||
|
||||
// CellContext defines the properties and formatting state of an individual table cell.
|
||||
type CellContext struct {
|
||||
Data string // Content to be displayed in the cell, provided by the caller
|
||||
Align Align // Text alignment within the cell (Left, Right, Center, Skip)
|
||||
Padding Padding // Padding characters surrounding the cell content
|
||||
Width int // Suggested width (often overridden by Row.Widths)
|
||||
Merge MergeState // Details about cell spanning across rows or columns
|
||||
}
|
||||
|
||||
// MergeState captures how a cell merges across different directions.
|
||||
type MergeState struct {
|
||||
Vertical MergeStateOption // Properties for vertical merging (across rows)
|
||||
Horizontal MergeStateOption // Properties for horizontal merging (across columns)
|
||||
Hierarchical MergeStateOption // Properties for nested/hierarchical merging
|
||||
}
|
||||
|
||||
// MergeStateOption represents common attributes for merging in a specific direction.
|
||||
type MergeStateOption struct {
|
||||
Present bool // True if this merge direction is active
|
||||
Span int // Number of cells this merge spans
|
||||
Start bool // True if this cell is the starting point of the merge
|
||||
End bool // True if this cell is the ending point of the merge
|
||||
}
|
||||
|
||||
// RowContext manages layout properties and relationships for a row and its columns.
|
||||
// It maintains state about the current row and its neighbors for proper rendering.
|
||||
type RowContext struct {
|
||||
Position Position // Section of the table (Header, Row, Footer)
|
||||
Location Location // Boundary position (First, Middle, End)
|
||||
Current map[int]CellContext // Cells in this row, indexed by column
|
||||
Previous map[int]CellContext // Cells from the row above; nil if none
|
||||
Next map[int]CellContext // Cells from the row below; nil if none
|
||||
Widths Mapper[int, int] // Computed widths for each column
|
||||
ColMaxWidths CellWidth // Maximum allowed width per column
|
||||
}
|
||||
|
||||
func (r RowContext) GetCell(col int) CellContext {
|
||||
return r.Current[col]
|
||||
}
|
||||
|
||||
// Separators controls the visibility of separators in the table.
|
||||
type Separators struct {
|
||||
ShowHeader State // Controls header separator visibility
|
||||
ShowFooter State // Controls footer separator visibility
|
||||
BetweenRows State // Determines if lines appear between rows
|
||||
BetweenColumns State // Determines if separators appear between columns
|
||||
}
|
||||
|
||||
// Lines manages the visibility of table boundary lines.
|
||||
type Lines struct {
|
||||
ShowTop State // Top border visibility
|
||||
ShowBottom State // Bottom border visibility
|
||||
ShowHeaderLine State // Header separator line visibility
|
||||
ShowFooterLine State // Footer separator line visibility
|
||||
}
|
||||
|
||||
// Settings holds configuration preferences for rendering behavior.
|
||||
type Settings struct {
|
||||
Separators Separators // Separator visibility settings
|
||||
Lines Lines // Line visibility settings
|
||||
CompactMode State // Reserved for future compact rendering (unused)
|
||||
// Cushion State
|
||||
}
|
||||
|
||||
// Border defines the visibility states of table borders.
|
||||
type Border struct {
|
||||
Left State // Left border visibility
|
||||
Right State // Right border visibility
|
||||
Top State // Top border visibility
|
||||
Bottom State // Bottom border visibility
|
||||
Overwrite bool
|
||||
}
|
||||
|
||||
type StreamConfig struct {
|
||||
Enable bool
|
||||
|
||||
// StrictColumns, if true, causes Append() to return an error
|
||||
// in streaming mode if the number of cells in an appended row
|
||||
// does not match the established number of columns for the stream.
|
||||
// If false (default), rows with mismatched column counts will be
|
||||
// padded or truncated with a warning log.
|
||||
StrictColumns bool
|
||||
|
||||
// Deprecated: Use top-level Config.Widths for streaming width control.
|
||||
// This field will be removed in a future version. It will be respected if
|
||||
// Config.Widths is not set and this field is.
|
||||
Widths CellWidth
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
package tw
|
||||
|
||||
import "slices"
|
||||
|
||||
// Slicer is a generic slice type that provides additional methods for slice manipulation.
|
||||
type Slicer[T any] []T
|
||||
|
||||
// NewSlicer creates and returns a new initialized Slicer.
|
||||
func NewSlicer[T any]() Slicer[T] {
|
||||
return make(Slicer[T], 0)
|
||||
}
|
||||
|
||||
// Get returns the element at the specified index.
|
||||
// Returns the zero value if the index is out of bounds or the slice is nil.
|
||||
func (s Slicer[T]) Get(index int) T {
|
||||
if s == nil || index < 0 || index >= len(s) {
|
||||
var zero T
|
||||
return zero
|
||||
}
|
||||
return s[index]
|
||||
}
|
||||
|
||||
// GetOK returns the element at the specified index and a boolean indicating whether the index was valid.
|
||||
func (s Slicer[T]) GetOK(index int) (T, bool) {
|
||||
if s == nil || index < 0 || index >= len(s) {
|
||||
var zero T
|
||||
return zero, false
|
||||
}
|
||||
return s[index], true
|
||||
}
|
||||
|
||||
// Append appends elements to the slice and returns the new slice.
|
||||
func (s Slicer[T]) Append(elements ...T) Slicer[T] {
|
||||
return append(s, elements...)
|
||||
}
|
||||
|
||||
// Prepend adds elements to the beginning of the slice and returns the new slice.
|
||||
func (s Slicer[T]) Prepend(elements ...T) Slicer[T] {
|
||||
return append(elements, s...)
|
||||
}
|
||||
|
||||
// Len returns the number of elements in the slice.
|
||||
// Returns 0 if the slice is nil.
|
||||
func (s Slicer[T]) Len() int {
|
||||
if s == nil {
|
||||
return 0
|
||||
}
|
||||
return len(s)
|
||||
}
|
||||
|
||||
// IsEmpty returns true if the slice is nil or has zero elements.
|
||||
func (s Slicer[T]) IsEmpty() bool {
|
||||
return s.Len() == 0
|
||||
}
|
||||
|
||||
// Has returns true if the index exists in the slice.
|
||||
func (s Slicer[T]) Has(index int) bool {
|
||||
return index >= 0 && index < s.Len()
|
||||
}
|
||||
|
||||
// First returns the first element of the slice, or the zero value if empty.
|
||||
func (s Slicer[T]) First() T {
|
||||
return s.Get(0)
|
||||
}
|
||||
|
||||
// Last returns the last element of the slice, or the zero value if empty.
|
||||
func (s Slicer[T]) Last() T {
|
||||
return s.Get(s.Len() - 1)
|
||||
}
|
||||
|
||||
// Each iterates over each element in the slice and calls the provided function.
|
||||
// Does nothing if the slice is nil.
|
||||
func (s Slicer[T]) Each(fn func(T)) {
|
||||
for _, v := range s {
|
||||
fn(v)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter returns a new Slicer containing only elements that satisfy the predicate.
|
||||
func (s Slicer[T]) Filter(fn func(T) bool) Slicer[T] {
|
||||
result := NewSlicer[T]()
|
||||
for _, v := range s {
|
||||
if fn(v) {
|
||||
result = result.Append(v)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Map returns a new Slicer with each element transformed by the provided function.
|
||||
func (s Slicer[T]) Map(fn func(T) T) Slicer[T] {
|
||||
result := NewSlicer[T]()
|
||||
for _, v := range s {
|
||||
result = result.Append(fn(v))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Contains returns true if the slice contains an element that satisfies the predicate.
|
||||
func (s Slicer[T]) Contains(fn func(T) bool) bool {
|
||||
return slices.ContainsFunc(s, fn)
|
||||
}
|
||||
|
||||
// Find returns the first element that satisfies the predicate, along with a boolean indicating if it was found.
|
||||
func (s Slicer[T]) Find(fn func(T) bool) (T, bool) {
|
||||
for _, v := range s {
|
||||
if fn(v) {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
var zero T
|
||||
return zero, false
|
||||
}
|
||||
|
||||
// Clone returns a shallow copy of the Slicer.
|
||||
func (s Slicer[T]) Clone() Slicer[T] {
|
||||
result := NewSlicer[T]()
|
||||
if s != nil {
|
||||
result = append(result, s...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// SlicerToMapper converts a Slicer of KeyValuePair to a Mapper.
|
||||
func SlicerToMapper[K comparable, V any](s Slicer[KeyValuePair[K, V]]) Mapper[K, V] {
|
||||
result := make(Mapper[K, V])
|
||||
if s == nil {
|
||||
return result
|
||||
}
|
||||
for _, pair := range s {
|
||||
result[pair.Key] = pair.Value
|
||||
}
|
||||
return result
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package tw
|
||||
|
||||
// State represents an on/off state
|
||||
type State int
|
||||
|
||||
// Public: Methods for State type
|
||||
|
||||
// Enabled checks if the state is on
|
||||
func (o State) Enabled() bool { return o == Success }
|
||||
|
||||
// Default checks if the state is unknown
|
||||
func (o State) Default() bool { return o == Unknown }
|
||||
|
||||
// Disabled checks if the state is off
|
||||
func (o State) Disabled() bool { return o == Fail }
|
||||
|
||||
// Toggle switches the state between on and off
|
||||
func (o State) Toggle() State {
|
||||
if o == Fail {
|
||||
return Success
|
||||
}
|
||||
return Fail
|
||||
}
|
||||
|
||||
// Cond executes a condition if the state is enabled
|
||||
func (o State) Cond(c func() bool) bool {
|
||||
if o.Enabled() {
|
||||
return c()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Or returns this state if enabled, else the provided state
|
||||
func (o State) Or(c State) State {
|
||||
if o.Enabled() {
|
||||
return o
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// String returns the string representation of the state
|
||||
func (o State) String() string {
|
||||
if o.Enabled() {
|
||||
return "on"
|
||||
}
|
||||
|
||||
if o.Disabled() {
|
||||
return "off"
|
||||
}
|
||||
return "undefined"
|
||||
}
|
||||
+1012
File diff suppressed because it is too large
Load Diff
+109
@@ -0,0 +1,109 @@
|
||||
package tw
|
||||
|
||||
// Operation Status Constants
|
||||
// Used to indicate the success or failure of operations
|
||||
const (
|
||||
Pending = 0 // Operation failed
|
||||
Fail = -1 // Operation failed
|
||||
Success = 1 // Operation succeeded
|
||||
|
||||
MinimumColumnWidth = 8
|
||||
|
||||
DefaultCacheStringCapacity = 10 * 1024 // 10 KB
|
||||
)
|
||||
|
||||
const (
|
||||
Empty = ""
|
||||
Skip = ""
|
||||
Space = " "
|
||||
NewLine = "\n"
|
||||
Column = ":"
|
||||
Dash = "-"
|
||||
)
|
||||
|
||||
// Feature State Constants
|
||||
// Represents enabled/disabled states for features
|
||||
const (
|
||||
Unknown State = Pending // Feature is enabled
|
||||
On State = Success // Feature is enabled
|
||||
Off State = Fail // Feature is disabled
|
||||
)
|
||||
|
||||
// Table Alignment Constants
|
||||
// Defines text alignment options for table content
|
||||
const (
|
||||
AlignNone Align = "none" // Center-aligned text
|
||||
AlignCenter Align = "center" // Center-aligned text
|
||||
AlignRight Align = "right" // Right-aligned text
|
||||
AlignLeft Align = "left" // Left-aligned text
|
||||
AlignDefault = AlignLeft // Left-aligned text
|
||||
)
|
||||
|
||||
const (
|
||||
Header Position = "header" // Table header section
|
||||
Row Position = "row" // Table row section
|
||||
Footer Position = "footer" // Table footer section
|
||||
)
|
||||
|
||||
const (
|
||||
LevelHeader Level = iota // Topmost line position
|
||||
LevelBody // LevelBody line position
|
||||
LevelFooter // LevelFooter line position
|
||||
)
|
||||
|
||||
const (
|
||||
LocationFirst Location = "first" // Topmost line position
|
||||
LocationMiddle Location = "middle" // LevelBody line position
|
||||
LocationEnd Location = "end" // LevelFooter line position
|
||||
)
|
||||
|
||||
const (
|
||||
SectionHeader = "header"
|
||||
SectionRow = "row"
|
||||
SectionFooter = "footer"
|
||||
)
|
||||
|
||||
// Text Wrapping Constants
|
||||
// Defines text wrapping behavior in table cells
|
||||
const (
|
||||
WrapNone = iota // No wrapping
|
||||
WrapNormal // Standard word wrapping
|
||||
WrapTruncate // Truncate text with ellipsis
|
||||
WrapBreak // Break words to fit
|
||||
)
|
||||
|
||||
// Cell Merge Constants
|
||||
// Specifies cell merging behavior in tables
|
||||
|
||||
const (
|
||||
MergeNone = iota // No merging
|
||||
MergeVertical // Merge cells vertically
|
||||
MergeHorizontal // Merge cells horizontally
|
||||
MergeBoth // Merge both vertically and horizontally
|
||||
MergeHierarchical // Hierarchical merging
|
||||
)
|
||||
|
||||
// Special Character Constants
|
||||
// Defines special characters used in formatting
|
||||
const (
|
||||
CharEllipsis = "…" // Ellipsis character for truncation
|
||||
CharBreak = "↩" // Break character for wrapping
|
||||
)
|
||||
|
||||
type Spot int
|
||||
|
||||
const (
|
||||
SpotNone Spot = iota
|
||||
SpotTopLeft
|
||||
SpotTopCenter
|
||||
SpotTopRight
|
||||
SpotBottomLeft
|
||||
SpotBottomCenter // Default for legacy SetCaption
|
||||
SpotBottomRight
|
||||
SpotLeftTop
|
||||
SpotLeftCenter
|
||||
SpotLeftBottom
|
||||
SpotRightTop
|
||||
SpotRightCenter
|
||||
SpotRIghtBottom
|
||||
)
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
// Package tw defines types and constants for table formatting and configuration,
|
||||
// including validation logic for various table properties.
|
||||
package tw
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/olekukonko/errors"
|
||||
) // Custom error handling library
|
||||
|
||||
// Position defines where formatting applies in the table (e.g., header, footer, or rows).
|
||||
type Position string
|
||||
|
||||
// Validate checks if the Position is one of the allowed values: Header, Footer, or Row.
|
||||
func (pos Position) Validate() error {
|
||||
switch pos {
|
||||
case Header, Footer, Row:
|
||||
return nil // Valid position
|
||||
}
|
||||
// Return an error for any unrecognized position
|
||||
return errors.New("invalid position")
|
||||
}
|
||||
|
||||
// Filter defines a function type for processing cell content.
|
||||
// It takes a slice of strings (representing cell data) and returns a processed slice.
|
||||
type Filter func([]string) []string
|
||||
|
||||
// Formatter defines an interface for types that can format themselves into a string.
|
||||
// Used for custom formatting of table cell content.
|
||||
type Formatter interface {
|
||||
Format() string // Returns the formatted string representation
|
||||
}
|
||||
|
||||
// Counter defines an interface that combines io.Writer with a method to retrieve a total.
|
||||
// This is used by the WithCounter option to allow for counting lines, bytes, etc.
|
||||
type Counter interface {
|
||||
io.Writer // It must be a writer to be used in io.MultiWriter.
|
||||
Total() int
|
||||
}
|
||||
|
||||
// Align specifies the text alignment within a table cell.
|
||||
type Align string
|
||||
|
||||
// Validate checks if the Align is one of the allowed values: None, Center, Left, or Right.
|
||||
func (a Align) Validate() error {
|
||||
switch a {
|
||||
case AlignNone, AlignCenter, AlignLeft, AlignRight:
|
||||
return nil // Valid alignment
|
||||
}
|
||||
// Return an error for any unrecognized alignment
|
||||
return errors.New("invalid align")
|
||||
}
|
||||
|
||||
type Alignment []Align
|
||||
|
||||
func (a Alignment) String() string {
|
||||
var str strings.Builder
|
||||
for i, a := range a {
|
||||
if i > 0 {
|
||||
str.WriteString("; ")
|
||||
}
|
||||
str.WriteString(strconv.Itoa(i))
|
||||
str.WriteString("=")
|
||||
str.WriteString(string(a))
|
||||
}
|
||||
return str.String()
|
||||
}
|
||||
|
||||
func (a Alignment) Add(aligns ...Align) Alignment {
|
||||
aa := make(Alignment, len(aligns))
|
||||
copy(aa, aligns)
|
||||
return aa
|
||||
}
|
||||
|
||||
func (a Alignment) Set(col int, align Align) Alignment {
|
||||
if col >= 0 && col < len(a) {
|
||||
a[col] = align
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// Copy creates a new independent copy of the Alignment
|
||||
func (a Alignment) Copy() Alignment {
|
||||
aa := make(Alignment, len(a))
|
||||
copy(aa, a)
|
||||
return aa
|
||||
}
|
||||
|
||||
// Level indicates the vertical position of a line in the table (e.g., header, body, or footer).
|
||||
type Level int
|
||||
|
||||
// Validate checks if the Level is one of the allowed values: Header, Body, or Footer.
|
||||
func (l Level) Validate() error {
|
||||
switch l {
|
||||
case LevelHeader, LevelBody, LevelFooter:
|
||||
return nil // Valid level
|
||||
}
|
||||
// Return an error for any unrecognized level
|
||||
return errors.New("invalid level")
|
||||
}
|
||||
|
||||
// Location specifies the horizontal position of a cell or column within a table row.
|
||||
type Location string
|
||||
|
||||
// Validate checks if the Location is one of the allowed values: First, Middle, or End.
|
||||
func (l Location) Validate() error {
|
||||
switch l {
|
||||
case LocationFirst, LocationMiddle, LocationEnd:
|
||||
return nil // Valid location
|
||||
}
|
||||
// Return an error for any unrecognized location
|
||||
return errors.New("invalid location")
|
||||
}
|
||||
|
||||
type Caption struct {
|
||||
Text string
|
||||
Spot Spot
|
||||
Align Align
|
||||
Width int
|
||||
}
|
||||
|
||||
func (c Caption) WithText(text string) Caption {
|
||||
c.Text = text
|
||||
return c
|
||||
}
|
||||
|
||||
func (c Caption) WithSpot(spot Spot) Caption {
|
||||
c.Spot = spot
|
||||
return c
|
||||
}
|
||||
|
||||
func (c Caption) WithAlign(align Align) Caption {
|
||||
c.Align = align
|
||||
return c
|
||||
}
|
||||
|
||||
func (c Caption) WithWidth(width int) Caption {
|
||||
c.Width = width
|
||||
return c
|
||||
}
|
||||
|
||||
type Control struct {
|
||||
Hide State
|
||||
}
|
||||
|
||||
// Compact configures compact width optimization for merged cells.
|
||||
type Compact struct {
|
||||
Merge State // Merge enables compact width calculation during cell merging, optimizing space allocation.
|
||||
}
|
||||
|
||||
// Struct holds settings for struct-based operations like AutoHeader.
|
||||
type Struct struct {
|
||||
// AutoHeader automatically extracts and sets headers from struct fields when Bulk is called with a slice of structs.
|
||||
// Uses JSON tags if present, falls back to field names (title-cased). Skips unexported or json:"-" fields.
|
||||
// Enabled by default for convenience.
|
||||
AutoHeader State
|
||||
|
||||
// Tags is a priority-ordered list of struct tag keys to check for header names.
|
||||
// The first tag found on a field will be used. Defaults to ["json", "db"].
|
||||
Tags []string
|
||||
}
|
||||
|
||||
// Behavior defines settings that control table rendering behaviors, such as column visibility and content formatting.
|
||||
type Behavior struct {
|
||||
AutoHide State // AutoHide determines whether empty columns are hidden. Ignored in streaming mode.
|
||||
TrimSpace State // TrimSpace determines trimming of leading and trailing spaces from cell content.
|
||||
TrimLine State // TrimLine determines whether empty visual lines within a cell are collapsed.
|
||||
TrimTab State // TrimTab determines trimming of leading and trailing tabs from cell content.
|
||||
|
||||
Header Control // Header specifies control settings for the table header.
|
||||
Footer Control // Footer specifies control settings for the table footer.
|
||||
|
||||
// Compact enables optimized width calculation for merged cells, such as in horizontal merges,
|
||||
// by systematically determining the most efficient width instead of scaling by the number of columns.
|
||||
Compact Compact
|
||||
|
||||
// Structs contains settings for how struct data is processed.
|
||||
Structs Struct
|
||||
}
|
||||
|
||||
// Padding defines the spacing characters around cell content in all four directions.
|
||||
// A zero-value Padding struct will use the table's default padding unless Overwrite is true.
|
||||
type Padding struct {
|
||||
Left string
|
||||
Right string
|
||||
Top string
|
||||
Bottom string
|
||||
|
||||
// Overwrite forces tablewriter to use this padding configuration exactly as specified,
|
||||
// even when empty. When false (default), empty Padding fields will inherit defaults.
|
||||
//
|
||||
// For explicit no-padding, use the PaddingNone constant instead of setting Overwrite.
|
||||
Overwrite bool
|
||||
}
|
||||
|
||||
// Common padding configurations for convenience
|
||||
|
||||
// Equals reports whether two Padding configurations are identical in all fields.
|
||||
// This includes comparing the Overwrite flag as part of the equality check.
|
||||
func (p Padding) Equals(padding Padding) bool {
|
||||
return p.Left == padding.Left &&
|
||||
p.Right == padding.Right &&
|
||||
p.Top == padding.Top &&
|
||||
p.Bottom == padding.Bottom &&
|
||||
p.Overwrite == padding.Overwrite
|
||||
}
|
||||
|
||||
// Empty reports whether all padding strings are empty (all fields == "").
|
||||
// Note that an Empty padding may still take effect if Overwrite is true.
|
||||
func (p Padding) Empty() bool {
|
||||
return p.Left == "" && p.Right == "" && p.Top == "" && p.Bottom == ""
|
||||
}
|
||||
|
||||
// Paddable reports whether this Padding configuration should override existing padding.
|
||||
// Returns true if either:
|
||||
// - Any padding string is non-empty (!p.Empty())
|
||||
// - Overwrite flag is true (even with all strings empty)
|
||||
//
|
||||
// This is used internally during configuration merging to determine whether to
|
||||
// apply the padding settings.
|
||||
func (p Padding) Paddable() bool {
|
||||
return !p.Empty() || p.Overwrite
|
||||
}
|
||||
|
||||
// LineCounter is the default implementation of the Counter interface.
|
||||
// It counts the number of newline characters written to it.
|
||||
type LineCounter struct {
|
||||
count int
|
||||
}
|
||||
|
||||
// Write implements the io.Writer interface, counting newlines in the input.
|
||||
// It uses a pointer receiver to modify the internal count.
|
||||
func (lc *LineCounter) Write(p []byte) (n int, err error) {
|
||||
lc.count += bytes.Count(p, []byte{'\n'})
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// Total implements the Counter interface, returning the final count.
|
||||
func (lc *LineCounter) Total() int {
|
||||
return lc.count
|
||||
}
|
||||
+1736
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user