Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,10 @@ colorB := dotenvgo.WithLoader[Color](loaderB, "THEME").Get() // Red
| `bool` | `true`, `false`, `1`, `0`, `yes`, `no`, `on`, `off` |
| `time.Duration` | `30s`, `1h30m`, `500ms` |
| `*time.Location` | `America/New_York`, `UTC` |
| `*string`, `*int`, `*uint`, `*float64`, `*bool`, `*time.Duration` | same values as their non-pointer equivalents |
| `*[]string`, `*[]int`, `*[]uint`, `*[]float64`, `*[]bool` | comma-separated values, or custom separators with `sep` |
| `[]*string`, `[]*int`, `[]*uint`, `[]*float64`, `[]*bool` | comma-separated values, each item loaded as a pointer |
| `*Struct` | nested config loaded from prefixed child fields such as `DB_URL` |
| `[]string` | `a,b,c` |
| `[]int`, `[]int8-64` | `1,2,3` |
| `[]uint`, `[]uint8-64` | `1,2,3` |
Expand All @@ -188,6 +192,8 @@ colorB := dotenvgo.WithLoader[Color](loaderB, "THEME").Get() // Red
| `required` | Fail if missing | `required:"true"` |
| `sep` | Slice separator | `sep:";"` |

Pointer fields are supported during struct loading. For scalar pointer fields such as `*string` or `*int`, the loader allocates the pointer when a value or default exists. Pointer-to-slice fields such as `*[]string` work the same way. Slice-of-pointer fields such as `[]*string` and `[]*int` are also supported for leaf types. For nested pointer structs such as `*Database`, the loader allocates the struct when at least one nested field is loaded, for example from `DB_URL` or nested defaults.

## `.env` File Format

```bash
Expand Down Expand Up @@ -239,6 +245,7 @@ See [examples/](./examples) for complete working code:
| [struct](./examples/struct) | Struct-based config |
| [nested_prefix](./examples/nested_prefix) | Nested structs with env tag prefixes |
| [empty_values](./examples/empty_values) | Missing vs empty value semantics |
| [pointer_slices](./examples/pointer_slices) | Pointer to slice and slice of pointers |
| [file](./examples/file) | Loading `.env` files |
| [expansion](./examples/expansion) | Variable expansion |
| [isolated_loader](./examples/isolated_loader) | Isolated loader demo |
Expand Down
75 changes: 75 additions & 0 deletions examples/pointer_slices/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// Example: Pointer to slice and slice of pointers
package main

import (
"fmt"
"os"

"github.com/godeh/dotenvgo"
)

type Config struct {
Hosts *[]string `env:"HOSTS"`
DefaultIDs *[]int `env:"DEFAULT_IDS" default:"10,20,30"`
Workers []*string `env:"WORKERS"`
Ports []*int `env:"PORTS" sep:";"`
}

func main() {
os.Setenv("HOSTS", "api,worker")
os.Setenv("WORKERS", "alpha,beta")
os.Setenv("PORTS", "8080; 9090;10010;57770")

var cfg Config
if err := dotenvgo.Load(&cfg); err != nil {
fmt.Printf("Failed to load config: %v\n", err)
os.Exit(1)
}

fmt.Println("=== Pointer Slice Configuration ===")
hosts := derefStringSlice(cfg.Hosts)
defaultIDs := derefIntSlice(cfg.DefaultIDs)
workers := derefStringPointers(cfg.Workers)
ports := derefIntPointers(cfg.Ports)

fmt.Printf("Hosts: %v - %v\n", len(hosts), hosts)
fmt.Printf("DefaultIDs: %v - %v\n", len(defaultIDs), defaultIDs)
fmt.Printf("Workers: %v - %v\n", len(workers), workers)
fmt.Printf("Ports: %v - %v\n", len(ports), ports)
}

func derefStringSlice(values *[]string) []string {
if values == nil {
return nil
}
return *values
}

func derefIntSlice(values *[]int) []int {
if values == nil {
return nil
}
return *values
}

func derefStringPointers(values []*string) []string {
result := make([]string, 0, len(values))
for _, value := range values {
if value == nil {
continue
}
result = append(result, *value)
}
return result
}

func derefIntPointers(values []*int) []int {
result := make([]int, 0, len(values))
for _, value := range values {
if value == nil {
continue
}
result = append(result, *value)
}
return result
}
49 changes: 27 additions & 22 deletions loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,29 +235,13 @@ func (l *Loader) setField(field reflect.Value, tag reflect.StructTag, value stri
// 0. Handle custom separator for slices
if field.Kind() == reflect.Slice {
sep := tag.Get("sep")
if sep != "" {
parts := strings.Split(value, sep)
slice := reflect.MakeSlice(field.Type(), 0, len(parts))
elemType := field.Type().Elem()

// Find parser for element type
parser, ok := l.getParser(elemType)
if !ok {
// Fallback to TextUnmarshaler for element?
// For now error if no parser for element
return fmt.Errorf("dotenvgo: no parser registered for slice element type %v", elemType)
if sep != "" || field.Type().Elem().Kind() == reflect.Pointer {
if sep == "" {
sep = ","
}

for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
parsed, err := parser(p)
if err != nil {
return err
}
slice = reflect.Append(slice, reflect.ValueOf(parsed))
slice, err := l.parseSlice(field.Type(), tag, value, sep)
if err != nil {
return err
}
field.Set(slice)
return nil
Expand Down Expand Up @@ -287,3 +271,24 @@ func (l *Loader) setField(field reflect.Value, tag reflect.StructTag, value stri

return fmt.Errorf("dotenvgo: no parser registered for type %v", field.Type())
}

func (l *Loader) parseSlice(sliceType reflect.Type, tag reflect.StructTag, value, sep string) (reflect.Value, error) {
parts := strings.Split(value, sep)
slice := reflect.MakeSlice(sliceType, 0, len(parts))
elemType := sliceType.Elem()

for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}

elem := reflect.New(elemType).Elem()
if err := l.setField(elem, tag, part); err != nil {
return reflect.Value{}, fmt.Errorf("dotenvgo: no parser registered for slice element type %v: %w", elemType, err)
}
slice = reflect.Append(slice, elem)
}

return slice, nil
}
80 changes: 80 additions & 0 deletions loader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,86 @@ func TestPointerLeafTypes(t *testing.T) {
}
}

func TestPointerSlices(t *testing.T) {
t.Run("Pointer To Slice", func(t *testing.T) {
type PointerSliceConfig struct {
Hosts *[]string `env:"HOSTS"`
Ports *[]int `env:"PORTS" default:"8080,9090"`
}

setEnv(t, "HOSTS", "api,worker")

var cfg PointerSliceConfig
if err := Load(&cfg); err != nil {
t.Fatalf("Load failed: %v", err)
}

if cfg.Hosts == nil {
t.Fatal("Expected Hosts to be set")
}
if len(*cfg.Hosts) != 2 || (*cfg.Hosts)[0] != "api" || (*cfg.Hosts)[1] != "worker" {
t.Errorf("Expected Hosts [api worker], got %v", *cfg.Hosts)
}
if cfg.Ports == nil {
t.Fatal("Expected Ports default to be set")
}
if len(*cfg.Ports) != 2 || (*cfg.Ports)[0] != 8080 || (*cfg.Ports)[1] != 9090 {
t.Errorf("Expected Ports [8080 9090], got %v", *cfg.Ports)
}
})

t.Run("Slice Of Pointers", func(t *testing.T) {
type SlicePointerConfig struct {
Hosts []*string `env:"HOSTS"`
IDs []*int `env:"IDS" sep:";"`
}

setEnv(t, "HOSTS", "api,worker")
setEnv(t, "IDS", "1; 2;3")

var cfg SlicePointerConfig
if err := Load(&cfg); err != nil {
t.Fatalf("Load failed: %v", err)
}

if len(cfg.Hosts) != 2 {
t.Fatalf("Expected 2 Hosts, got %d", len(cfg.Hosts))
}
if cfg.Hosts[0] == nil || cfg.Hosts[1] == nil {
t.Fatal("Expected all Host pointers to be non-nil")
}
if *cfg.Hosts[0] != "api" || *cfg.Hosts[1] != "worker" {
t.Errorf("Expected Hosts [api worker], got [%q %q]", *cfg.Hosts[0], *cfg.Hosts[1])
}

if len(cfg.IDs) != 3 {
t.Fatalf("Expected 3 IDs, got %d", len(cfg.IDs))
}
for i, expected := range []int{1, 2, 3} {
if cfg.IDs[i] == nil {
t.Fatalf("Expected ID pointer at index %d to be non-nil", i)
}
if *cfg.IDs[i] != expected {
t.Errorf("Expected ID %d at index %d, got %d", expected, i, *cfg.IDs[i])
}
}
})

t.Run("Pointer To Slice Remains Nil When Missing", func(t *testing.T) {
type PointerSliceConfig struct {
Hosts *[]string `env:"HOSTS"`
}

var cfg PointerSliceConfig
if err := Load(&cfg); err != nil {
t.Fatalf("Load failed: %v", err)
}
if cfg.Hosts != nil {
t.Errorf("Expected Hosts to remain nil, got %v", *cfg.Hosts)
}
})
}

func TestLoadWithPrefix(t *testing.T) {
setEnv(t, "APP_REQUIRED_VAR", "req")
setEnv(t, "APP_HOST", "app-host")
Expand Down
Loading