diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f6f244..1ab0e6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,10 +12,38 @@ The format is based on [Keep a Changelog], and this project adheres to ## [Unreleased] +This release is largely centered around a refactoring of the `Config` type and +the way that a `Printer` is configured. It should not affect most users, but +does introduce some breaking changes for filter authors. + ### Added -- Added `Annotator` type and `Config.Annotators` configuration, to add - user-defined annotations to values. +- Added `Annotator`, `Config.Annotators` and `WithAnnotator`, to add + user-defined text annotations to rendered values. +- Added `NewPrinter()` function, that accepts the following functional options: + - `WithFilter()` + - `WithDefaultFilters()` + - `WithAnnotator()` + - `WithUnexportedStructFields()` + - `WithPackagePaths()` + +### Changed + +- **[BC]** Replaced `Config.OmitUnexportedFields` with `RenderUnexportedStructFields`, note the logic is inverted. +- **[BC]** Replaced `Config.OmitPackagePaths` with `RenderPackagePaths`, note the logic is inverted. +- Bumped the minimum supported Go version to 1.21. + +### Removed + +- **[BC]** Removed `DefaultPrinter`, use `NewPrinter()` instead. +- **[BC]** Removed `Config.Indent` and `DefaultIndent` constant. +- **[BC]** Removed `Config.RecursionMarker` and `DefaultRecursionMarker` constant. +- **[BC]** Removed `Config.ZeroValueMarker` and `DefaultZeroValueMarker` constant. + +### Fixed + +- `Renderer.Config()` and `Renderer.WithModifiedConfig()` now properly clone the + slices within `Config`. ## [0.5.3] - 2024-04-08 diff --git a/annotator_test.go b/annotator_test.go index 0c82e57..f49d323 100644 --- a/annotator_test.go +++ b/annotator_test.go @@ -161,12 +161,14 @@ func TestPrinter_Annotator(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - cfg := DefaultPrinter.Config - cfg.Annotators = c.Annotators + var options []Option + for _, a := range c.Annotators { + options = append(options, WithAnnotator(a)) + } - testWithConfig( + testWithPrinter( t, - cfg, + NewPrinter(options...), c.Name, c.Value, c.Output..., diff --git a/filter.go b/filter.go index ca0f7da..364f824 100644 --- a/filter.go +++ b/filter.go @@ -6,3 +6,12 @@ package dapper // The filter uses r to render v. If r is unused v is rendered using the default // formatting logic. type Filter func(r Renderer, v Value) + +var defaultFilters = []Filter{ + StringerFilter, // always first + ErrorFilter, + ProtoFilter, + ReflectFilter, + SyncFilter, + TimeFilter, +} diff --git a/filter_proto.go b/filter_proto.go index 1a4df4f..f0a408d 100644 --- a/filter_proto.go +++ b/filter_proto.go @@ -8,7 +8,7 @@ func ProtoFilter(r Renderer, v Value) { r. WithModifiedConfig( func(c *Config) { - c.OmitUnexportedFields = true + c.RenderUnexportedStructFields = false }, ). WriteValue(v) diff --git a/filter_test.go b/filter_test.go index 70e1d03..e87fb45 100644 --- a/filter_test.go +++ b/filter_test.go @@ -8,7 +8,7 @@ import ( . "github.com/dogmatiq/dapper" ) -func TestPrinter_Filter(t *testing.T) { +func TestPrinter_WithFilter(t *testing.T) { t.Run("it is passed a valid format function", func(t *testing.T) { type testType struct { i int @@ -18,34 +18,32 @@ func TestPrinter_Filter(t *testing.T) { i: 100, } - p := &Printer{ - Config: Config{ - Filters: []Filter{ - func(r Renderer, v Value) { - if v.DynamicType != reflect.TypeOf(testType{}) { - return - } - - r.Print("github.com/dogmatiq/dapper_test.testType<") - - fv := v.Value.FieldByName("i") - - r.WriteValue( - Value{ - Value: fv, - DynamicType: fv.Type(), - StaticType: fv.Type(), - IsAmbiguousDynamicType: false, - IsAmbiguousStaticType: false, - IsUnexported: true, - }, - ) - - r.Print(">") - }, + p := NewPrinter( + WithFilter( + func(r Renderer, v Value) { + if v.DynamicType != reflect.TypeOf(testType{}) { + return + } + + r.Print("github.com/dogmatiq/dapper_test.testType<") + + fv := v.Value.FieldByName("i") + + r.WriteValue( + Value{ + Value: fv, + DynamicType: fv.Type(), + StaticType: fv.Type(), + IsAmbiguousDynamicType: false, + IsAmbiguousStaticType: false, + IsUnexported: true, + }, + ) + + r.Print(">") }, - }, - } + ), + ) expected := fmt.Sprintf("github.com/dogmatiq/dapper_test.testType<%d>", tt.i) t.Log("expected:\n\n" + expected + "\n") diff --git a/kind_struct.go b/kind_struct.go index cb44fe0..eec56cc 100644 --- a/kind_struct.go +++ b/kind_struct.go @@ -34,12 +34,12 @@ func renderStructKind(r Renderer, v Value) { } func renderStructFields(r Renderer, v Value) error { - omitUnexported := r.Config().OmitUnexportedFields - alignment := longestFieldName(v.DynamicType, omitUnexported) + renderUnexported := r.Config().RenderUnexportedStructFields + alignment := longestFieldName(v.DynamicType, renderUnexported) for i := 0; i < v.DynamicType.NumField(); i++ { f := v.DynamicType.Field(i) - if omitUnexported && isUnexportedField(f) { + if !renderUnexported && isUnexportedField(f) { continue } @@ -79,12 +79,12 @@ func isUnexportedField(f reflect.StructField) bool { } // longestFieldName returns the length of the longest field name in a struct. -func longestFieldName(rt reflect.Type, exportedOnly bool) int { +func longestFieldName(rt reflect.Type, includeUnexported bool) int { width := 0 for i := 0; i < rt.NumField(); i++ { f := rt.Field(i) - if !exportedOnly || !isUnexportedField(f) { + if includeUnexported || !isUnexportedField(f) { n := len(f.Name) if n > width { diff --git a/kind_struct_test.go b/kind_struct_test.go index 27ec761..2f71421 100644 --- a/kind_struct_test.go +++ b/kind_struct_test.go @@ -76,10 +76,8 @@ func TestPrinter_StructFieldTypes(t *testing.T) { } // Verifies not exported fields in a struct are omitted when configured to do so -func TestPrinter_StructUnexportedFieldsWithOmitUnexpoted(t *testing.T) { - config := DefaultPrinter.Config - config.OmitUnexportedFields = true - printer := &Printer{Config: config} +func TestPrinter_WithUnexportedStructFields(t *testing.T) { + printer := NewPrinter(WithUnexportedStructFields(false)) writer := &strings.Builder{} _, err := printer.Write(writer, struct { diff --git a/printer.go b/printer.go index 013b014..6e2a6df 100644 --- a/printer.go +++ b/printer.go @@ -4,6 +4,7 @@ import ( "io" "os" "reflect" + "slices" "strings" "sync" @@ -26,36 +27,114 @@ const ( annotationSuffix = ">>" ) -// Config holds the configuration for a printer. +// Printer generates human-readable representations of Go values. +// +// The output format is intended to be as minimal as possible, without being +// ambiguous. To that end, type information is only included where it can not be +// reliably inferred from the structure of the value. +type Printer struct { + cfg Config +} + +// Option controls the behavior of a printer. +type Option func(*Config) + +// Config is the configuration for a printer. type Config struct { + // Annotators is a set of functions that can annotate values with additional + // information, regardless of whether the value is rendered by a filter or + // the default rendering logic. + Annotators []Annotator + // Filters is the set of filters to apply when formatting values. // // Filters are applied in the order they are provided. If any filter renders // output all subsequent filters and the default rendering logic are // skipped. Any annotations are still applied. - Filters []Filter + Filters []Filter + applyDefaultFilters bool - // Annotators is a set of functions that can annotate values with additional - // information, regardless of whether the value is rendered by a filter or - // the default rendering logic. - Annotators []Annotator + // RenderPackagePaths, when true, causes the printer to render the + // fully-qualified package path when rendering type names. + RenderPackagePaths bool - // OmitPackagePaths, when true, causes the printer to omit the - // fully-qualified package path from the rendered type names. - OmitPackagePaths bool + // RenderUnexportedStructFields, when true, causes the printer to render + // unexported struct fields. + RenderUnexportedStructFields bool +} - // OmitUnexportedFields omits unexported struct fields when set to true - OmitUnexportedFields bool +func (c Config) clone() Config { + c.Annotators = slices.Clone(c.Annotators) + c.Filters = slices.Clone(c.Filters) + return c } -// Printer generates human-readable representations of Go values. +// WithAnnotator adds an [Annotator] to the printer. // -// The output format is intended to be as minimal as possible, without being -// ambiguous. To that end, type information is only included where it can not be -// reliably inferred from the structure of the value. -type Printer struct { - // Config is the configuration for the printer. - Config Config +// Annotators are used to add supplementary textual information to the output of +// the printer. They are applied after the value has been rendered by any +// filters or the default rendering logic. +func WithAnnotator(a Annotator) Option { + return func(cfg *Config) { + cfg.Annotators = append(cfg.Annotators, a) + } +} + +// WithFilter adds a [Filter] to be applied when formatting values. +// +// Filters allow overriding the default rendering logic for specific types or +// values. They are applied in the order they are provided. If any filter +// renders output all subsequent filters and the default rendering logic are +// skipped. Any annotations are still applied. +// +// Filters added by [WithFilter] take precedence over the default filters, which +// can be disabled using [WithDefaultFilters]. +func WithFilter(f Filter) Option { + return func(cfg *Config) { + cfg.Filters = append(cfg.Filters, f) + } +} + +// WithDefaultFilters enables or disables the default [Filter] set. +func WithDefaultFilters(enabled bool) Option { + return func(cfg *Config) { + cfg.applyDefaultFilters = !enabled + } +} + +// WithPackagePaths controls whether the printer renders the fully-qualified +// package path in type names. This option is enabled by default. +func WithPackagePaths(show bool) Option { + return func(cfg *Config) { + cfg.RenderPackagePaths = show + } +} + +// WithUnexportedStructFields controls whether the printer renders unexported +// struct fields. This option is enabled by default. +func WithUnexportedStructFields(show bool) Option { + return func(opts *Config) { + opts.RenderUnexportedStructFields = show + } +} + +// NewPrinter returns a new [Printer] with the given options applied. +func NewPrinter(options ...Option) *Printer { + cfg := Config{ + applyDefaultFilters: true, + RenderPackagePaths: true, + RenderUnexportedStructFields: true, + } + + for _, opt := range options { + opt(&cfg) + } + + if cfg.applyDefaultFilters { + cfg.Filters = append(cfg.Filters, defaultFilters...) + } + + return &Printer{cfg} } // panicSentinel is a panic value that wraps an error that must be returned from @@ -79,18 +158,16 @@ func (p *Printer) Write(w io.Writer, v any) (_ int, err error) { } }() - cfg := p.Config - counter := &stream.Counter{ Target: w, } r := &renderer{ + cfg: p.cfg, Indenter: stream.Indenter{ Target: counter, }, - Configuration: cfg, - RecursionSet: map[uintptr]struct{}{}, + RecursionSet: map[uintptr]struct{}{}, } rv := reflect.ValueOf(v) @@ -127,31 +204,20 @@ func (p *Printer) Format(v any) string { return b.String() } -// DefaultPrinter is the printer used by [Write], [Format] and [Print]. -var DefaultPrinter = Printer{ - Config: Config{ - Filters: []Filter{ - StringerFilter, // always first - ErrorFilter, - ProtoFilter, - ReflectFilter, - SyncFilter, - TimeFilter, - }, - }, -} +// defaultPrinter is the printer used by [Write], [Format] and [Print]. +var defaultPrinter = NewPrinter() // Write writes a pretty-printed representation of v to w using // [DefaultPrinter]. // // It returns the number of bytes written. func Write(w io.Writer, v any) (int, error) { - return DefaultPrinter.Write(w, v) + return defaultPrinter.Write(w, v) } // Format returns a pretty-printed representation of v using [DefaultPrinter]. func Format(v any) string { - return DefaultPrinter.Format(v) + return defaultPrinter.Format(v) } var ( diff --git a/printer_test.go b/printer_test.go index 2dfbf3f..aac3b39 100644 --- a/printer_test.go +++ b/printer_test.go @@ -8,7 +8,7 @@ import ( . "github.com/dogmatiq/dapper" ) -func ExamplePrinter() { +func ExampleNewPrinter() { type TreeNode struct { Name string Value any @@ -31,7 +31,7 @@ func ExamplePrinter() { }, } - p := &Printer{} + p := NewPrinter() s := p.Format(v) fmt.Println(s) @@ -53,7 +53,7 @@ func ExamplePrinter() { // } } -func ExamplePrinter_Config() { +func ExampleNewPrinter_options() { type TreeNode struct { Name string Value any @@ -76,11 +76,7 @@ func ExamplePrinter_Config() { }, } - p := &Printer{ - Config: Config{ - OmitPackagePaths: true, - }, - } + p := NewPrinter(WithPackagePaths(false)) s := p.Format(v) fmt.Println(s) diff --git a/renderer.go b/renderer.go index e8294c6..30cf665 100644 --- a/renderer.go +++ b/renderer.go @@ -31,9 +31,10 @@ type Renderer interface { } type renderer struct { + cfg Config + Indenter stream.Indenter ProducedOutput bool - Configuration Config RecursionSet map[uintptr]struct{} FilterIndex int FilterValue *Value @@ -48,7 +49,7 @@ func (r *renderer) Write(data []byte) (int, error) { } func (r *renderer) Config() Config { - return r.Configuration + return r.cfg.clone() } func (r *renderer) Print(format string, args ...any) { @@ -59,7 +60,7 @@ func (r *renderer) Print(format string, args ...any) { func (r *renderer) FormatType(v Value) string { var w strings.Builder - r.child(&w, r.Configuration).WriteType(v) + r.child(&w, r.cfg).WriteType(v) return w.String() } @@ -67,25 +68,25 @@ func (r *renderer) WriteType(v Value) { t := v.DynamicType if t.Name() != "" { - renderType(r, r.Configuration, t) + renderType(r, r.cfg, t) return } switch t.Kind() { case reflect.Chan: - renderChanType(r, r.Configuration, t) + renderChanType(r, r.cfg, t) case reflect.Func: - renderFuncType(r, r.Configuration, t) + renderFuncType(r, r.cfg, t) case reflect.Map: - renderMapType(r, r.Configuration, t) + renderMapType(r, r.cfg, t) case reflect.Ptr: - renderPtrType(r, r.Configuration, t) + renderPtrType(r, r.cfg, t) case reflect.Array: - renderArrayType(r, r.Configuration, t) + renderArrayType(r, r.cfg, t) case reflect.Slice: - renderSliceType(r, r.Configuration, t) + renderSliceType(r, r.cfg, t) default: - renderType(r, r.Configuration, t) + renderType(r, r.cfg, t) } } @@ -93,7 +94,7 @@ func renderType(r Renderer, c Config, t reflect.Type) { pkg := t.PkgPath() name := t.Name() - if c.OmitPackagePaths || name == "" || pkg == "" { + if !c.RenderPackagePaths || name == "" || pkg == "" { name = t.String() } else { name = pkg + "." + name @@ -112,7 +113,7 @@ func renderType(r Renderer, c Config, t reflect.Type) { func (r *renderer) FormatValue(v Value) string { var w strings.Builder - r.child(&w, r.Configuration).WriteValue(v) + r.child(&w, r.cfg).WriteValue(v) return w.String() } @@ -121,7 +122,7 @@ func (r *renderer) WriteValue(v Value) { if !isFilterValue { var annotations []string - for _, annotate := range r.Configuration.Annotators { + for _, annotate := range r.cfg.Annotators { if a := annotate(v); a != "" { annotations = append(annotations, a) } @@ -160,12 +161,12 @@ func (r *renderer) WriteValue(v Value) { v.Value = unsafereflect.MakeMutable(v.Value) - for index, filter := range r.Configuration.Filters { + for index, filter := range r.cfg.Filters { if r.FilterIndex == index && isFilterValue { continue } - child := r.child(r, r.Configuration) + child := r.child(r, r.cfg) child.FilterIndex = index child.FilterValue = &v @@ -223,7 +224,7 @@ func (r *renderer) Outdent() { } func (r *renderer) WithModifiedConfig(modify func(*Config)) Renderer { - c := r.Configuration + c := r.cfg.clone() modify(&c) return r.child(r, c) } @@ -233,10 +234,10 @@ func (r *renderer) child(w io.Writer, c Config) *renderer { Indenter: stream.Indenter{ Target: w, }, - Configuration: c, - RecursionSet: r.RecursionSet, - FilterIndex: r.FilterIndex, - FilterValue: r.FilterValue, + cfg: c, + RecursionSet: r.RecursionSet, + FilterIndex: r.FilterIndex, + FilterValue: r.FilterValue, } } diff --git a/renderer_test.go b/renderer_test.go index 71e7870..5de8daa 100644 --- a/renderer_test.go +++ b/renderer_test.go @@ -15,18 +15,18 @@ func test( lines ...string, ) { t.Helper() - testWithConfig( + testWithPrinter( t, - DefaultPrinter.Config, + NewPrinter(), n, v, lines..., ) } -func testWithConfig( +func testWithPrinter( t *testing.T, - cfg Config, + p *Printer, n string, v any, lines ...string, @@ -34,7 +34,6 @@ func testWithConfig( t.Helper() x := strings.Join(lines, "\n") - p := &Printer{cfg} t.Run( n,