Skip to content

Commit

Permalink
add Follow and ExploreOption (#3)
Browse files Browse the repository at this point in the history
* work on placement options

* tryout explore opts

* work on Follow

* work on follow

* working follow

* add option to follow

* add SameRow, SameColumn

* add tests, fix zero mapkey check

* add int tests

* see what disable cache means

* add comments
emicklei authored Dec 3, 2024

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
1 parent 8352743 commit 0c5a81e
Showing 15 changed files with 338 additions and 79 deletions.
3 changes: 2 additions & 1 deletion .github/workflows/go.yml
Original file line number Diff line number Diff line change
@@ -19,7 +19,8 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.22'
go-version: '1.23'
cache: false

- name: Test
run: go test -race -coverprofile=coverage.txt -covermode=atomic .
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
examples/dump/structexplorer.html
structexplorer.html
5 changes: 5 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
### v0.6.0

- add Follow to service for navigation of structs
- add ExploreOption for control of where to put a struct

### v0.5.0

- add "c" (clear) button for not-root removals
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -55,7 +55,9 @@ This means that if you have started the `structexplorer` service in your program
The explorer can also be asked to dump an HTML page with the current state of values to a file.

s := structexplorer.NewService()
s.Explore("some structure", yourStruct, "some field", yourStruct.Field).Dump()
s.Explore("yours", yourStruct)
s.Follow("yours.field")
s.Dump()

Another method is to use a special test case which starts an explorer at the end of a test and then run it with a longer acceptable timeout.

17 changes: 14 additions & 3 deletions examples/dump/dump_test.go
Original file line number Diff line number Diff line change
@@ -2,27 +2,38 @@ package main

import (
"testing"
"time"

"github.com/emicklei/structexplorer"
)

type thing struct {
val string
arr []int
}

func TestWatch(t *testing.T) {
svc := structexplorer.NewService()

o := &thing{val: "shoe"}
o := &thing{val: "shoe", arr: []int{1, 2, 3}}
svc.Explore("thing", o).Dump()

// put a breakpoint here and open the written HTML file to see the current explorer state.
o.val = "brush"

o2 := &thing{val: "blue"}
svc.Explore("thing2", o2).Dump()
svc.Explore("thing2", o2) // without option starts at 0,0

o.val = "belt"
svc.Follow("thing.arr", structexplorer.RowColumn(2, 2))
svc.Follow("thing2.arr", structexplorer.OnColumn(1))

svc.Follow("thing2.non-existing")
svc.Dump()

// explore more
svc.Explore("now", time.Now(), structexplorer.RowColumn(1, 0))

// modify after svc creation
o.val = "belt"
svc.Dump()
}
4 changes: 2 additions & 2 deletions examples/yourself/main.go
Original file line number Diff line number Diff line change
@@ -5,8 +5,8 @@ import (
)

func main() {
m := map[string]any{}
m := map[string]any{"service": nil}
s := structexplorer.NewService("explorer", m)
m["value"] = s
m["service"] = s
s.Start()
}
35 changes: 35 additions & 0 deletions explore_options.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package structexplorer

type placementFunc func(e *explorer) (newRow, newColumn int)

// ExploreOption is a type for the options that can be passed to the Explore or Follow function.
type ExploreOption struct {
placement placementFunc
}

// RowColumn places the next object in the specified row and column.
func RowColumn(row, column int) ExploreOption {
return ExploreOption{
placement: func(e *explorer) (newRow, newColumn int) {
return row, column
},
}
}

// OnColumn places the next object in the same column on a new free row.
func OnColumn(column int) ExploreOption {
return ExploreOption{
placement: func(e *explorer) (newRow, newColumn int) {
return e.nextFreeRow(column) + 1, column
},
}
}

// OnRow places the next object in the same row on a new free column.
func OnRow(row int) ExploreOption {
return ExploreOption{
placement: func(e *explorer) (newRow, newColumn int) {
return row, e.nextFreeColumn(row) + 1
},
}
}
44 changes: 34 additions & 10 deletions explorer.go
Original file line number Diff line number Diff line change
@@ -26,12 +26,12 @@ func (o objectAccess) isEmpty() bool {
}

type explorer struct {
mutex *sync.Mutex // to protect concurrent access to the map
accessMap map[int]map[int]objectAccess
options *Options // some properties can be modified by user
mutex *sync.Mutex // to protect concurrent access to the map
accessMap map[int]map[int]objectAccess // row -> column -> objectAccess
options *Options // some properties can be modified by user
}

func (e *explorer) maxColumn(row int) int {
func (e *explorer) nextFreeColumn(row int) int {
max := 0
cols, ok := e.accessMap[row]
if !ok {
@@ -45,6 +45,18 @@ func (e *explorer) maxColumn(row int) int {
return max
}

func (e *explorer) nextFreeRow(column int) int {
for row, cols := range e.accessMap {
_, ok := cols[column]
if ok {
// cell taken
} else {
return row
}
}
return 0
}

func (e *explorer) rootKeys() (list []string) {
for _, row := range e.accessMap {
for _, access := range row {
@@ -54,6 +66,17 @@ func (e *explorer) rootKeys() (list []string) {
return
}

func (e *explorer) rootAccessWithLabel(label string) (oa objectAccess, row int, col int, ok bool) {
for row, rows := range e.accessMap {
for col, each := range rows {
if each.isRoot && each.label == label {
return each, row, col, true
}
}
}
return objectAccess{}, 0, 0, false
}

func newExplorerOnAll(labelValuePairs ...any) *explorer {
s := &explorer{
accessMap: map[int]map[int]objectAccess{},
@@ -72,14 +95,14 @@ func newExplorerOnAll(labelValuePairs ...any) *explorer {
slog.Info("value can not be explored", "value", value)
continue
}
s.putObjectOnRowStartingAtColumn(row, 0, objectAccess{
s.putObjectStartingAt(row, 0, objectAccess{
isRoot: true,
object: value,
path: []string{""},
label: label,
hideZeros: true,
typeName: fmt.Sprintf("%T", value),
})
}, OnRow(row))
row++
}
return s
@@ -112,10 +135,10 @@ func (e *explorer) removeObjectAt(row, col int) {
func (e *explorer) updateObjectAt(row, col int, updater func(access objectAccess) objectAccess) {
old := e.objectAt(row, col)
e.removeObjectAt(row, col)
e.putObjectOnRowStartingAtColumn(row, col, updater(old))
e.putObjectStartingAt(row, col, updater(old), OnRow(row))
}

func (e *explorer) putObjectOnRowStartingAtColumn(row, col int, access objectAccess) {
func (e *explorer) putObjectStartingAt(row, col int, access objectAccess, option ExploreOption) {
r, ok := e.accessMap[row]
if !ok {
r = map[int]objectAccess{}
@@ -126,8 +149,9 @@ func (e *explorer) putObjectOnRowStartingAtColumn(row, col int, access objectAcc
r[col] = access
return
}
// cell is taken
e.putObjectOnRowStartingAtColumn(row, e.maxColumn(row)+1, access)
// cell is taken, use option to find a new location
newRow, newCol := option.placement(e)
e.putObjectStartingAt(newRow, newCol, access, option)
}

func (e *explorer) buildIndexData(b *indexDataBuilder) indexData {
6 changes: 3 additions & 3 deletions explorer_test.go
Original file line number Diff line number Diff line change
@@ -22,7 +22,7 @@ func TestExplorerClear(t *testing.T) {
if got, want := len(x.accessMap), 1; got != want {
t.Errorf("got [%[1]v:%[1]T] want [%[2]v:%[2]T]", got, want)
}
x.putObjectOnRowStartingAtColumn(1, 1, objectAccess{})
x.putObjectStartingAt(1, 1, objectAccess{}, OnRow(0))
if got, want := len(x.accessMap), 2; got != want {
t.Errorf("got [%[1]v:%[1]T] want [%[2]v:%[2]T]", got, want)
}
@@ -52,12 +52,12 @@ func TestExplorerTable(t *testing.T) {
t.Errorf("got [%v]:%T want [%v]:%T", got, got, want, want)
}
o2 := objectAccess{object: 1}
x.putObjectOnRowStartingAtColumn(1, 1, o2)
x.putObjectStartingAt(1, 1, o2, OnColumn(1))
o3 := x.objectAt(1, 1)
if o2.object != o3.object {
t.Fail()
}
if got, want := x.maxColumn(1), 1; got != want {
if got, want := x.nextFreeColumn(1), 1; got != want {
t.Errorf("got [%v]:%T want [%v]:%T", got, got, want, want)
}
if !x.canRemoveObjectAt(1, 1) {
60 changes: 50 additions & 10 deletions field.go
Original file line number Diff line number Diff line change
@@ -83,38 +83,78 @@ func (f fieldAccess) value() any {
switch keyType.Kind() {
case reflect.Int:
i, _ := strconv.Atoi(f.key)
return rv.MapIndex(reflect.ValueOf(i)).Interface()
mv := rv.MapIndex(reflect.ValueOf(i))
if mv.IsZero() || !mv.CanInterface() {
return nil
}
return mv.Interface()
case reflect.Int8:
i, _ := strconv.ParseInt(f.key, 10, 8)
return rv.MapIndex(reflect.ValueOf(int8(i))).Interface()
mv := rv.MapIndex(reflect.ValueOf(int8(i)))
if mv.IsZero() || !mv.CanInterface() {
return nil
}
return mv.Interface()
case reflect.Int16:
i, _ := strconv.ParseInt(f.key, 10, 16)
return rv.MapIndex(reflect.ValueOf(int16(i))).Interface()
mv := rv.MapIndex(reflect.ValueOf(int16(i)))
if mv.IsZero() || !mv.CanInterface() {
return nil
}
return mv.Interface()
case reflect.Int32:
i, _ := strconv.ParseInt(f.key, 10, 32)
return rv.MapIndex(reflect.ValueOf(int32(i))).Interface()
mv := rv.MapIndex(reflect.ValueOf(int32(i)))
if mv.IsZero() || !mv.CanInterface() {
return nil
}
return mv.Interface()
case reflect.Int64:
i, _ := strconv.ParseInt(f.key, 10, 64)
return rv.MapIndex(reflect.ValueOf(i)).Interface()
mv := rv.MapIndex(reflect.ValueOf(i))
if mv.IsZero() || !mv.CanInterface() {
return nil
}
return mv.Interface()
case reflect.Uint:
i, _ := strconv.ParseUint(f.key, 10, 0)
return rv.MapIndex(reflect.ValueOf(uint(i))).Interface()
case reflect.Uint8:
i, _ := strconv.ParseUint(f.key, 10, 8)
return rv.MapIndex(reflect.ValueOf(uint8(i))).Interface()
mv := rv.MapIndex(reflect.ValueOf(uint8(i)))
if mv.IsZero() || !mv.CanInterface() {
return nil
}
return mv.Interface()
case reflect.Uint16:
i, _ := strconv.ParseUint(f.key, 10, 16)
return rv.MapIndex(reflect.ValueOf(uint16(i))).Interface()
mv := rv.MapIndex(reflect.ValueOf(uint16(i)))
if mv.IsZero() {
return nil
}
return mv.Interface()
case reflect.Uint32:
i, _ := strconv.ParseUint(f.key, 10, 32)
return rv.MapIndex(reflect.ValueOf(uint32(i))).Interface()
mv := rv.MapIndex(reflect.ValueOf(uint32(i)))
if mv.IsZero() || !mv.CanInterface() {
return nil
}
return mv.Interface()
case reflect.Uint64:
i, _ := strconv.ParseUint(f.key, 10, 64)
return rv.MapIndex(reflect.ValueOf(uint64(i))).Interface()
mv := rv.MapIndex(reflect.ValueOf(uint64(i)))
if mv.IsZero() || !mv.CanInterface() {
return nil
}
return mv.Interface()
}
// fallback: name is hash of key
key := stringToReflectMapKey(f.key, rv)
return rv.MapIndex(key).Interface()
mv := rv.MapIndex(key)
if mv.IsZero() || !mv.CanInterface() {
return nil
}
return mv.Interface()
}
if rv.Type().Kind() == reflect.Struct {
// name is field
51 changes: 51 additions & 0 deletions field_test.go
Original file line number Diff line number Diff line change
@@ -37,6 +37,14 @@ func Test_valueAtAccessPathFloat64(t *testing.T) {
}
}

func Test_valueAtAccessPathSliceIndex(t *testing.T) {
v := struct{ a []int }{[]int{1}}
w := valueAtAccessPath(v, []string{"a", "0"})
if got, want := w, 1; got != want {
t.Errorf("got [%v]:%T want [%v]:%T", got, got, want, want)
}
}

type object struct {
i int
pi *int
@@ -423,3 +431,46 @@ func TestStringSliceWithEmpty(t *testing.T) {
t.Errorf("got [%[1]v:%[1]T] want [%[2]v:%[2]T]", got, want)
}
}

func TestIntKeyedMap(t *testing.T) {
{
if got, want := newFields(map[int8]int8{1: 2})[0].value(), int8(2); got != want {
t.Errorf("got [%[1]v:%[1]T] want [%[2]v:%[2]T]", got, want)
}
}
{
if got, want := newFields(map[int16]int16{1: 2})[0].value(), int16(2); got != want {
t.Errorf("got [%[1]v:%[1]T] want [%[2]v:%[2]T]", got, want)
}
}
{
if got, want := newFields(map[int32]int32{1: 2})[0].value(), int32(2); got != want {
t.Errorf("got [%[1]v:%[1]T] want [%[2]v:%[2]T]", got, want)
}
}
{
if got, want := newFields(map[int64]int64{1: 2})[0].value(), int64(2); got != want {
t.Errorf("got [%[1]v:%[1]T] want [%[2]v:%[2]T]", got, want)
}
}
{
if got, want := newFields(map[uint8]uint8{1: 2})[0].value(), uint8(2); got != want {
t.Errorf("got [%[1]v:%[1]T] want [%[2]v:%[2]T]", got, want)
}
}
{
if got, want := newFields(map[uint16]uint16{1: 2})[0].value(), uint16(2); got != want {
t.Errorf("got [%[1]v:%[1]T] want [%[2]v:%[2]T]", got, want)
}
}
{
if got, want := newFields(map[uint32]uint32{1: 2})[0].value(), uint32(2); got != want {
t.Errorf("got [%[1]v:%[1]T] want [%[2]v:%[2]T]", got, want)
}
}
{
if got, want := newFields(map[uint64]uint64{1: 2})[0].value(), uint64(2); got != want {
t.Errorf("got [%[1]v:%[1]T] want [%[2]v:%[2]T]", got, want)
}
}
}
10 changes: 8 additions & 2 deletions index_builder.go
Original file line number Diff line number Diff line change
@@ -41,7 +41,8 @@ func (b *indexDataBuilder) build(row, column int, access objectAccess) cellInfo
// copy fields into entries
hasZeros := false
entries := []fieldEntry{}
for _, each := range newFields(access.Value()) {
currentValue := access.Value()
for _, each := range newFields(currentValue) {
valString := safeComputeValueString(each)
if isZeroPrintstring(valString) {
hasZeros = true
@@ -73,13 +74,18 @@ func (b *indexDataBuilder) build(row, column int, access objectAccess) cellInfo
fieldListLabel += strings.Repeat(" ", size-len(access.label))
}
newSelectID := fmt.Sprintf("id%d", b.seq)
typ := access.typeName
if typ == "" {
// when using Follow, the type is not set/known
typ = fmt.Sprintf("%T", currentValue)
}
b.data.Rows[row].Cells[column] = fieldList{
Row: row,
Column: column,
Path: strings.Join(access.path, "."),
Label: template.HTML(fieldListLabel),
Fields: entries,
Type: access.typeName,
Type: typ,
IsRoot: access.isRoot,
HasZeros: hasZeros,
SelectSize: len(entries),
101 changes: 54 additions & 47 deletions service.go
Original file line number Diff line number Diff line change
@@ -12,6 +12,22 @@ import (
"strings"
)

// Service is an HTTP Handler to explore one or more values (structures).
type Service interface {
http.Handler
// Start accepts 0 or 1 Options
Start(opts ...Options)

// Dump writes an HTML file for displaying the current state of the explorer and its entries.
Dump()

// Explore adds a new entry (next available row in column 0) for a value unless it cannot be explored.
Explore(label string, value any, options ...ExploreOption) Service

// Follow adds a new entry for a value at the specified path unless it cannot be explored.
Follow(path string, options ...ExploreOption) Service
}

//go:embed index_tmpl.html
var indexHTML string

@@ -24,53 +40,11 @@ func (s *service) init() {
s.indexTemplate = tmpl
}

// Options can be used to configure a Service on startup.
type Options struct {
// Uses 5656 as the default
HTTPPort int
// Uses http.DefaultServeMux as default
ServeMux *http.ServeMux
// Uses "/" as default
HTTPBasePath string
}

func (o *Options) rootPath() string {
if o.HTTPBasePath == "" {
return "/"
}
return path.Join("/", o.HTTPBasePath)
}

func (o *Options) httpPort() int {
if o.HTTPPort == 0 {
return 5656
}
return o.HTTPPort
}

func (o *Options) serveMux() *http.ServeMux {
if o.ServeMux == nil {
return http.DefaultServeMux
}
return o.ServeMux
}

type service struct {
explorer *explorer
indexTemplate *template.Template
}

// Service is an HTTP Handler to explore one or more values (structures).
type Service interface {
http.Handler
// Start accepts 0 or 1 Options
Start(opts ...Options)
// Dump writes an HTML file for displaying the current state of the explorer and its entries.
Dump()
// Explore adds a new entry (next available row in column 0) for a value unless it cannot be explored.
Explore(label string, value any) Service
}

// NewService creates a new to explore one or more values (structures).
func NewService(labelValuePairs ...any) Service {
s := &service{explorer: newExplorerOnAll(labelValuePairs...)}
@@ -131,21 +105,29 @@ func (s *service) serveIndex(w http.ResponseWriter, _ *http.Request) {
}

// Explore adds a new entry (next available row in column 0) for a value if it can be explored.
func (s *service) Explore(label string, value any) Service {
func (s *service) Explore(label string, value any, options ...ExploreOption) Service {
defer s.protect()()

row, column := 0, 0
placement := OnRow(row)
if len(options) > 0 {
placement = options[0]
row, column = options[0].placement(s.explorer)
}
if !canExplore(value) {
slog.Info("value can not be explored", "value", value)
return s
}
s.explorer.putObjectOnRowStartingAtColumn(0, 0, objectAccess{
oa := objectAccess{
isRoot: true,
object: value,
path: []string{""},
label: label,
hideZeros: true,
typeName: fmt.Sprintf("%T", value),
})
}

s.explorer.putObjectStartingAt(row, column, oa, placement)
return s
}

@@ -235,11 +217,36 @@ func (s *service) serveInstructions(w http.ResponseWriter, r *http.Request) {
// other keys
v = oa.Value()
if !canExplore(v) {
slog.Warn("[structexplorer] cannot explore this", "value", v, "type", fmt.Sprintf("%T", v))
slog.Warn("[structexplorer] cannot explore this", "value", v, "path", oa.label, "type", fmt.Sprintf("%T", v))
continue
}
}
oa.typeName = fmt.Sprintf("%T", v)
s.explorer.putObjectOnRowStartingAtColumn(toRow, toColumn, oa)
s.explorer.putObjectStartingAt(toRow, toColumn, oa, OnRow(toRow))
}
}

func (s *service) Follow(newPath string, options ...ExploreOption) Service {
if newPath == "" {
return s
}
pathTokens := strings.Split(newPath, ".")
// find root
root, row, col, ok := s.explorer.rootAccessWithLabel(pathTokens[0])
if !ok {
slog.Warn("[structexplorer] object not found", "label", pathTokens[0])
return s
}
oa := objectAccess{
object: root.object,
path: pathTokens[1:],
label: newPath,
hideZeros: true,
}
placement := OnRow(row)
if len(options) > 0 {
placement = options[0]
}
s.explorer.putObjectStartingAt(row, col, oa, placement)
return s
}
37 changes: 37 additions & 0 deletions service_options.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package structexplorer

import (
"net/http"
"path"
)

// Options can be used to configure a Service on startup.
type Options struct {
// Uses 5656 as the default
HTTPPort int
// Uses http.DefaultServeMux as default
ServeMux *http.ServeMux
// Uses "/" as default
HTTPBasePath string
}

func (o *Options) rootPath() string {
if o.HTTPBasePath == "" {
return "/"
}
return path.Join("/", o.HTTPBasePath)
}

func (o *Options) httpPort() int {
if o.HTTPPort == 0 {
return 5656
}
return o.HTTPPort
}

func (o *Options) serveMux() *http.ServeMux {
if o.ServeMux == nil {
return http.DefaultServeMux
}
return o.ServeMux
}
39 changes: 39 additions & 0 deletions service_test.go
Original file line number Diff line number Diff line change
@@ -43,3 +43,42 @@ func TestServe(t *testing.T) {
t.Errorf("got [%[1]v:%[1]T] want [%[2]v:%[2]T]", got, want)
}
}

func TestServiceFollow(t *testing.T) {
s := NewService("now", time.Now()).(*service)
s.Follow("now.loc")
oa := s.explorer.accessMap[0][1]
if got, want := oa.label, "now.loc"; got != want {
t.Errorf("got [%[1]v:%[1]T] want [%[2]v:%[2]T]", got, want)
}
s.Follow("now.ext", RowColumn(1, 1))
oa = s.explorer.accessMap[1][1]
if got, want := oa.label, "now.ext"; got != want {
t.Errorf("got [%[1]v:%[1]T] want [%[2]v:%[2]T]", got, want)
}
}

func TestServiceExplore(t *testing.T) {
s := NewService().(*service)
s.Explore("now", time.Now())
oa := s.explorer.accessMap[0][0]
if got, want := oa.label, "now"; got != want {
t.Errorf("got [%[1]v:%[1]T] want [%[2]v:%[2]T]", got, want)
}
s.Dump()
}
func TestServiceExploreWithOption(t *testing.T) {
s := NewService().(*service)
s.Explore("now", time.Now(), RowColumn(2, 2))
oa := s.explorer.accessMap[2][2]
if got, want := oa.label, "now"; got != want {
t.Errorf("got [%[1]v:%[1]T] want [%[2]v:%[2]T]", got, want)
}
}
func TestServicEmptyFollow(t *testing.T) {
s := NewService().(*service)
s.Follow("")
if len(s.explorer.accessMap) != 0 {
t.Fail()
}
}

0 comments on commit 0c5a81e

Please sign in to comment.