Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
.idea
.github
lab
+21
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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...)
}
+3
View File
@@ -0,0 +1,3 @@
# Created by .ignore support plugin (hsz.mobi)
.idea/
tmp/
+21
View File
@@ -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.
File diff suppressed because it is too large Load Diff
+598
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
+437
View File
@@ -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 *Errors 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 isnt 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
View File
@@ -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
View File
@@ -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 isnt 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 instances 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 packages 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
}
+5
View File
@@ -0,0 +1,5 @@
.idea
lab
tmp
#_*
_test/
+37
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
// structs 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 Loggers 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 Loggers 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 loggers 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 defaultLoggers Info method.
// Thread-safe via the Loggers 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
// defaultLoggers Infof method. Thread-safe via the Loggers 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 defaultLoggers 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 defaultLoggers 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 defaultLoggers 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 defaultLoggers 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 defaultLoggers 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 defaultLoggers 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 defaultLoggers 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 defaultLoggers 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 loggers context, modifying it directly.
// It mutates the default loggers 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 loggers 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 loggers 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 loggers 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 loggers 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 loggers 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 Middlewares 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 Loggers 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 defaultLoggers Print method. Thread-safe via the Loggers 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 defaultLoggers Printf method. Thread-safe via
// the Loggers 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 functions 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 Loggers 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 Loggers 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 defaultLoggers settings (level, style, namespaces, etc.) but with
// an independent context, allowing customization without affecting the global logger.
// Thread-safe via the Loggers 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 loggers 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 Loggers 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 values 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 FieldBuilders 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 FieldBuilders 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 loggers 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
View File
@@ -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
View File
@@ -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 structs 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
View File
@@ -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
}
}
}
File diff suppressed because it is too large Load Diff
+163
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+140
View File
@@ -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
View File
@@ -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
View File
@@ -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 loggers 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
View File
@@ -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
View File
@@ -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
View File
@@ -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 loggers 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 loggers 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+466
View File
@@ -0,0 +1,466 @@
ASCII Table Writer
=========
[![ci](https://github.com/olekukonko/tablewriter/workflows/ci/badge.svg?branch=master)](https://github.com/olekukonko/tablewriter/actions?query=workflow%3Aci)
[![Total views](https://img.shields.io/sourcegraph/rrc/github.com/olekukonko/tablewriter.svg)](https://sourcegraph.com/github.com/olekukonko/tablewriter)
[![Godoc](https://godoc.org/github.com/olekukonko/tablewriter?status.svg)](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
![Table with Color](https://cloud.githubusercontent.com/assets/6460392/21101956/bbc7b356-c0a1-11e6-9f36-dba694746efc.png)
#### 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
![Table cells with Color](https://user-images.githubusercontent.com/9064687/63969376-bcd88d80-ca6f-11e9-9466-c3d954700b25.png)
#### 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
View File
@@ -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
File diff suppressed because it is too large Load Diff
+94
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 (132).
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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+79
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
}
File diff suppressed because it is too large Load Diff
+109
View File
@@ -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
View File
@@ -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
}
File diff suppressed because it is too large Load Diff