Skip to content

Commit

Permalink
Add configurable formatting (#37)
Browse files Browse the repository at this point in the history
  • Loading branch information
ianlopshire committed Apr 6, 2020
2 parents 7cd39dd + 9b40237 commit 54451b9
Show file tree
Hide file tree
Showing 8 changed files with 324 additions and 33 deletions.
25 changes: 22 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,16 @@ Package fixedwidth provides encoding and decoding for fixed-width formatted Data
## Usage

### Struct Tags
Position within a line is controlled via struct tags.
The tags should be formatted as `fixed:"{startPos},{endPos}"` where `startPos` and `endPos` are both positive integers greater than 0.
Positions start at 1. The interval is inclusive. Fields without tags are ignored.

The struct tag schema schema used by fixedwidth is: `fixed:"{startPos},{endPos},[{alignment},[{padChar}]]"`<sup id="a1">[1](#f1)</sup>.

The `startPos` and `endPos` arguments control the position within a line. `startPos` and `endPos` must both be positive integers greater than 0. Positions start at 1. The interval is inclusive.

The `alignment` argument controls the alignment of the value within it's interval. The valid options are `default`<sup id="a2">[2](#f2)</sup>, `right`, and `left`. The `alignment` is optional and can be omitted.

The `padChar` argument controls the character that will be used to pad any empty characters in the interval after writing the value. The default padding character is a space. The `padChar` is optional and can be omitted.

Fields without tags are ignored.

### Encode
```go
Expand Down Expand Up @@ -89,5 +96,17 @@ decoder.SetUseCodepointIndices(true)
// Decode as usual now
```

### Alignment Behavior

| Alignment | Encoding | Decoding |
| --------- | -------- | -------- |
| `default` | Field is left aligned | The padding character is trimmed from both right and left of value |
| `left` | Field is left aligned | The padding character is trimmed from right of value |
| `right` | Field is right aligned | The padding character is trimmed from left of value |

## Notes
1. <span id="f1">`{}` indicates an argument. `[]` indicates and optional segment [^](#a1)</span>
2. <span id="f2">The `default` alignment is similar to `left` but has slightly different behavior required to maintain backwards compatibility [^](#a2)</span>

## Licence
MIT
25 changes: 21 additions & 4 deletions decode.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,24 @@ func (d *Decoder) readLine(v reflect.Value) (err error, ok bool) {
return valueSetter(v, rawValue), true
}

func rawValueFromLine(value rawValue, startPos, endPos int) rawValue {
func rawValueFromLine(value rawValue, startPos, endPos int, format format) rawValue {
var trimFunc func(string) string

switch format.alignment {
case left:
trimFunc = func(s string) string {
return strings.TrimRight(s, string(format.padChar))
}
case right:
trimFunc = func(s string) string {
return strings.TrimLeft(s, string(format.padChar))
}
default:
trimFunc = func(s string) string {
return strings.Trim(s, string(format.padChar))
}
}

if value.codepointIndices != nil {
if len(value.codepointIndices) == 0 || startPos > len(value.codepointIndices) {
return rawValue{data: ""}
Expand All @@ -245,7 +262,7 @@ func rawValueFromLine(value rawValue, startPos, endPos int) rawValue {
lineData = value.data[relevantIndices[0]:value.codepointIndices[endPos]]
}
return rawValue{
data: strings.TrimSpace(lineData),
data: trimFunc(lineData),
codepointIndices: relevantIndices,
}
} else {
Expand All @@ -256,7 +273,7 @@ func rawValueFromLine(value rawValue, startPos, endPos int) rawValue {
endPos = len(value.data)
}
return rawValue{
data: strings.TrimSpace(value.data[startPos-1 : endPos]),
data: trimFunc(value.data[startPos-1 : endPos]),
}
}
}
Expand Down Expand Up @@ -299,7 +316,7 @@ func structSetter(t reflect.Type) valueSetter {
if !fieldSpec.ok {
continue
}
rawValue := rawValueFromLine(raw, fieldSpec.startPos, fieldSpec.endPos)
rawValue := rawValueFromLine(raw, fieldSpec.startPos, fieldSpec.endPos, fieldSpec.format)
err := fieldSpec.setter(v.Field(i), rawValue)
if err != nil {
sf := t.Field(i)
Expand Down
51 changes: 51 additions & 0 deletions decode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,57 @@ func TestUnmarshal(t *testing.T) {
})
}

func TestUnmarshal_format(t *testing.T) {
type H struct {
F1 string `fixed:"1,5,left"`
F2 string `fixed:"6,10,left,#"`
F3 string `fixed:"11,15,right"`
F4 string `fixed:"16,20,right,#"`
F5 string `fixed:"21,25,default"`
F6 string `fixed:"26,30,default,#"`
}

for _, tt := range []struct {
name string
rawValue []byte
target interface{}
expected interface{}
shouldErr bool
}{
{
name: "base case",
rawValue: []byte(`foo ` + `bar##` + ` baz` + `##biz` + ` bor ` + `#box#`),
target: &[]H{},
expected: &[]H{{"foo", "bar", "baz", "biz", "bor", "box"}},
shouldErr: false,
},
{
name: "keep spaces",
rawValue: []byte(` foo` + ` ##` + `baz ` + `## ` + ` bor ` + `#####`),
target: &[]H{},
expected: &[]H{{" foo", " ", "baz ", " ", "bor", ""}},
shouldErr: false,
},
{
name: "empty",
rawValue: []byte(` ` + `#####` + ` ` + `#####` + ` ` + `#####`),
target: &[]H{},
expected: &[]H{{"", "", "", "", "", ""}},
shouldErr: false,
},
} {
t.Run(tt.name, func(t *testing.T) {
err := Unmarshal(tt.rawValue, tt.target)
if tt.shouldErr != (err != nil) {
t.Errorf("Unmarshal() err want %v, have %v (%v)", tt.shouldErr, err != nil, err)
}
if !tt.shouldErr && !reflect.DeepEqual(tt.target, tt.expected) {
t.Errorf("Unmarshal() want %+v, have %+v", tt.expected, tt.target)
}
})
}
}

func TestNewValueSetter(t *testing.T) {
for _, tt := range []struct {
name string
Expand Down
34 changes: 32 additions & 2 deletions encode.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,36 @@ func newValueEncoder(t reflect.Type) valueEncoder {
return unknownTypeEncoder(t)
}

func (ve valueEncoder) Write(v reflect.Value, dst []byte, format format) error {
value, err := ve(v)
if err != nil {
return err
}

if len(value) < len(dst) {
switch {
case format.alignment == right:
padding := bytes.Repeat([]byte{format.padChar}, len(dst)-len(value))
copy(dst, padding)
copy(dst[len(padding):], value)
return nil

// The second case in this block is a special case to maintain backward
// compatibility. In previous versions of the library, only len(value) bytes were
// written to dst. This means overlapping intervals can, in effect, be used to
// coalesce a value.
case format.alignment == left, format.alignment == defaultAlignment && format.padChar != ' ':
padding := bytes.Repeat([]byte{format.padChar}, len(dst)-len(value))
copy(dst, value)
copy(dst[len(value):], padding)
return nil
}
}

copy(dst, value)
return nil
}

func structEncoder(v reflect.Value) ([]byte, error) {
ss := cachedStructSpec(v.Type())
dst := bytes.Repeat([]byte(" "), ss.ll)
Expand All @@ -165,12 +195,12 @@ func structEncoder(v reflect.Value) ([]byte, error) {
continue
}

val, err := spec.encoder(v.Field(i))
err := spec.encoder.Write(v.Field(i), dst[spec.startPos-1:spec.endPos:spec.endPos], spec.format)
if err != nil {
return nil, err
}
copy(dst[spec.startPos-1:spec.endPos:spec.endPos], val)
}

return dst, nil
}

Expand Down
94 changes: 94 additions & 0 deletions encode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,26 @@ func ExampleMarshal() {
// 1 Ian Lopshire 99.50
}

func ExampleMarshal_configurableFormatting() {
// define some data to encode
people := []struct {
ID int `fixed:"1,5,right,#"`
FirstName string `fixed:"6,15,right,#"`
LastName string `fixed:"16,25,right,#"`
Grade float64 `fixed:"26,30,right,#"`
}{
{1, "Ian", "Lopshire", 99.5},
}

data, err := Marshal(people)
if err != nil {
log.Fatal(err)
}
fmt.Printf("%s", data)
// Output:
// ####1#######Ian##Lopshire99.50
}

func TestMarshal(t *testing.T) {
type H struct {
F1 interface{} `fixed:"1,5"`
Expand Down Expand Up @@ -70,6 +90,80 @@ func TestMarshal(t *testing.T) {
}
}

func TestMarshal_format(t *testing.T) {
type H struct {
F1 string `fixed:"1,5,left"`
F2 string `fixed:"6,10,left,#"`
F3 string `fixed:"11,15,right"`
F4 string `fixed:"16,20,right,#"`
F5 string `fixed:"21,25,default"`
F6 string `fixed:"26,30,default,#"`
}

for _, tt := range []struct {
name string
v interface{}
want []byte
shouldErr bool
}{
{
name: "base case",
v: H{"foo", "bar", "biz", "baz", "bor", "box"},
want: []byte(`foo ` + `bar##` + ` biz` + `##baz` + `bor ` + `box##`),
shouldErr: false,
},
{
name: "empty",
v: H{"", "", "", "", "", ""},
want: []byte(` ` + `#####` + ` ` + `#####` + ` ` + `#####`),
shouldErr: false,
},
{
name: "overflow",
v: H{"12345678", "12345678", "12345678", "12345678", "12345678", "12345678"},
want: []byte(`12345` + `12345` + `12345` + `12345` + `12345` + `12345`),
shouldErr: false,
},
} {
t.Run(tt.name, func(t *testing.T) {
have, err := Marshal(tt.v)
if tt.shouldErr != (err != nil) {
t.Errorf("Marshal() err want %v, have %v (%v)", tt.shouldErr, err != nil, err)
}
if !bytes.Equal(tt.want, have) {
t.Errorf("Marshal() want %q, have %q", string(tt.want), string(have))
}
})
}
}

func TestMarshal_backwardCompatibility(t *testing.T) {
// Overlapping intervals can, in effect, be used to coalesce a value. This tests
// ensures this special does not break.
t.Run("interval overlap coalesce", func(t *testing.T) {
type H struct {
F1 string `fixed:"1,5"`
F2 string `fixed:"1,5"`
}

have, err := Marshal(H{F1: "val"})
if err != nil {
t.Fatalf("Marshal() unexpected error: %v", err)
}
if want := []byte(`val `); !bytes.Equal(have, want) {
t.Errorf("Marshal() want %q, have %q", string(want), string(have))
}

have, err = Marshal(H{F2: "val"})
if err != nil {
t.Fatalf("Marshal() unexpected error: %v", err)
}
if want := []byte(`val `); !bytes.Equal(have, want) {
t.Errorf("Marshal() want %q, have %q", string(want), string(have))
}
})
}

func TestNewValueEncoder(t *testing.T) {
for _, tt := range []struct {
name string
Expand Down
32 changes: 32 additions & 0 deletions format.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package fixedwidth

const (
defaultAlignment alignment = "default"
right alignment = "right"
left alignment = "left"
)

const (
defaultPadChar = ' '
)

var defaultFormat = format{
alignment: defaultAlignment,
padChar: defaultPadChar,
}

type format struct {
alignment alignment
padChar byte
}

type alignment string

func (a alignment) Valid() bool {
switch a {
case defaultAlignment, right, left:
return true
default:
return false
}
}
Loading

0 comments on commit 54451b9

Please sign in to comment.