From 97b2a25572085a8dfd2e9884ab923ba6ecf641c9 Mon Sep 17 00:00:00 2001 From: Matthew Rodusek <7519129+bitwizeshift@users.noreply.github.com> Date: Sun, 7 Jul 2024 21:37:42 -0400 Subject: [PATCH] Add initial system package This adds an initial system package for the FHIRPath implementation. The types are from the N1 specification, and are beginning with an initial set of types: * Booleans * Integers * Strings This is the first step in the implementation of the FHIRPath. This format is not expected to be the final format, since the `fhir` package is still itself unstable. --- go.mod | 7 +- go.sum | 6 ++ internal/esc/esc.go | 38 +++++++++++ system/any.go | 17 +++++ system/bool.go | 121 +++++++++++++++++++++++++++++++++ system/bool_test.go | 151 +++++++++++++++++++++++++++++++++++++++++ system/conv.go | 41 +++++++++++ system/doc.go | 21 ++++++ system/errors.go | 43 ++++++++++++ system/integer.go | 134 ++++++++++++++++++++++++++++++++++++ system/integer_test.go | 145 +++++++++++++++++++++++++++++++++++++++ system/string.go | 148 ++++++++++++++++++++++++++++++++++++++++ system/string_test.go | 133 ++++++++++++++++++++++++++++++++++++ 13 files changed, 1004 insertions(+), 1 deletion(-) create mode 100644 internal/esc/esc.go create mode 100644 system/any.go create mode 100644 system/bool.go create mode 100644 system/bool_test.go create mode 100644 system/conv.go create mode 100644 system/doc.go create mode 100644 system/errors.go create mode 100644 system/integer.go create mode 100644 system/integer_test.go create mode 100644 system/string.go create mode 100644 system/string_test.go diff --git a/go.mod b/go.mod index aac7670..8de2560 100644 --- a/go.mod +++ b/go.mod @@ -4,4 +4,9 @@ go 1.22.3 require github.com/antlr4-go/antlr/v4 v4.13.1 -require golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect +require github.com/google/go-cmp v0.6.0 // indirect + +require ( + github.com/friendly-fhir/go-fhir v0.0.0-20240627230005-9ef2174c1f29 + golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect +) diff --git a/go.sum b/go.sum index a4312cd..4ca6b77 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,10 @@ github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/friendly-fhir/go-fhir v0.0.0-20240627035249-eacfb3386af5 h1:D6ephfRcx17SrIHrWz2HTDu19Y6Zy1J9YTRyal0ZUnw= +github.com/friendly-fhir/go-fhir v0.0.0-20240627035249-eacfb3386af5/go.mod h1:dHmN8TwYULp9TAOI1D0cR1RpsIntTM75Y/aUq3v9fY0= +github.com/friendly-fhir/go-fhir v0.0.0-20240627230005-9ef2174c1f29 h1:8PXLQ1rNBD7CRPlDjJYaDFu3ZQFSsSnV5bl+3QUSs0k= +github.com/friendly-fhir/go-fhir v0.0.0-20240627230005-9ef2174c1f29/go.mod h1:dHmN8TwYULp9TAOI1D0cR1RpsIntTM75Y/aUq3v9fY0= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= diff --git a/internal/esc/esc.go b/internal/esc/esc.go new file mode 100644 index 0000000..a73f174 --- /dev/null +++ b/internal/esc/esc.go @@ -0,0 +1,38 @@ +/* +Package esc provides basic functionality for handling the ESC (escape) sequences +in the FHIRPath grammar. +*/ +package esc + +import ( + "fmt" + "strconv" + "strings" +) + +var escaper *strings.Replacer + +func init() { + escaper = strings.NewReplacer( + `\'`, `\u0027`, + `\"`, `\u0022`, + "\\`", `\u0060`, + "\\r", `\u000d`, + "\\n", `\u000a`, + "\\t", `\u0009`, + "\\f", `\u000c`, + "\\\\", `\u005c`, + ) +} + +func Parse(input string) (string, error) { + result := input + result = escaper.Replace(result) + // Re-escape any remaining quotes, so that Unquote won't fail. + result = strings.ReplaceAll(result, `"`, `\u0022`) + result, err := strconv.Unquote(fmt.Sprintf(`"%v"`, result)) + if err != nil { + return "", err + } + return result, nil +} diff --git a/system/any.go b/system/any.go new file mode 100644 index 0000000..d5a93ae --- /dev/null +++ b/system/any.go @@ -0,0 +1,17 @@ +package system + +// Any is the top-level system type for FHIRPath. +type Any interface { + isAny() +} + +// IsType checks whether a given type string is a valid FHIRPath System type +// name value. This function is case-sensitive. +func IsType(ty string) bool { + switch ty { + case "Boolean", "Integer", "Any", "Date", "DateTime", "Decimal", "Quantity", + "String", "Time": + return true + } + return false +} diff --git a/system/bool.go b/system/bool.go new file mode 100644 index 0000000..fb84bf4 --- /dev/null +++ b/system/bool.go @@ -0,0 +1,121 @@ +package system + +import ( + "encoding" + "encoding/json" + "fmt" + "strconv" + + fhir "github.com/friendly-fhir/go-fhir/r4/core" +) + +// Boolean is the FHIRPath system-type representation of the "boolean" value. +type Boolean bool + +// NewBoolean constructs a Boolean object with the underlying value. +// +// This function primarily exists for symmetry with the other constructor +// functions. +func NewBoolean(b bool) Boolean { + return Boolean(b) +} + +// ParseBoolean parses a string into the valid FHIRPath System.Boolean type. +func ParseBoolean(str string) (Boolean, error) { + switch str { + case "true": + return true, nil + case "false": + return false, nil + } + return false, newParseError[Boolean](str, nil) +} + +// MustParseBoolean parses a boolean string, and panics if the value is invalid. +func MustParseBoolean(str string) Boolean { + got, err := ParseBoolean(str) + if err != nil { + panic(err) + } + return got +} + +func (Boolean) isAny() {} + +// Negate returns the inverse polarity of this boolean value. +func (b Boolean) Negate() Boolean { + return !b +} + +// Bool returns the Go boolean representation of the System.Boolean. +func (b Boolean) Bool() bool { + return bool(b) +} + +// Formatting + +// String returns the string representation of the System.Boolean. +func (b Boolean) String() string { + return strconv.FormatBool(bool(b)) +} + +// Format implements the fmt.Formatter interface. +func (b Boolean) Format(s fmt.State, verb rune) { + fmt.Fprintf(s, "%"+string(verb), bool(b)) +} + +var ( + _ fmt.Stringer = (*Boolean)(nil) + _ fmt.Formatter = (*Boolean)(nil) +) + +// R4 conversions + +// FromR4 converts a FHIR Boolean type into a System.Boolean type. +func (b *Boolean) FromR4(r *fhir.Boolean) { + *b = Boolean(r.Value) +} + +// R4 converts this System.Boolean into a FHIR Boolean type. +func (b Boolean) R4() *fhir.Boolean { + return &fhir.Boolean{Value: bool(b)} +} + +// JSON conversions + +// MarshalJSON converts this Boolean object into a JSON object. +func (b Boolean) MarshalJSON() ([]byte, error) { + return json.Marshal(bool(b)) +} + +// UnmarshalJSON converts a JSON object into a Boolean object. +func (b *Boolean) UnmarshalJSON(data []byte) error { + var value bool + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *b = Boolean(value) + return nil +} + +var ( + _ json.Marshaler = (*Boolean)(nil) + _ json.Unmarshaler = (*Boolean)(nil) +) + +// Text conversions + +// MarshalText converts this Boolean object into a text object. +func (b Boolean) MarshalText() ([]byte, error) { + return b.MarshalJSON() +} + +// UnmarshalText converts a text object into a Boolean object. +func (b *Boolean) UnmarshalText(text []byte) error { + return b.UnmarshalJSON(text) +} + +var ( + _ encoding.TextMarshaler = (*Boolean)(nil) + _ encoding.TextUnmarshaler = (*Boolean)(nil) +) diff --git a/system/bool_test.go b/system/bool_test.go new file mode 100644 index 0000000..fc757fc --- /dev/null +++ b/system/bool_test.go @@ -0,0 +1,151 @@ +package system_test + +import ( + "errors" + "testing" + + fhir "github.com/friendly-fhir/go-fhir/r4/core" + "github.com/friendly-fhir/go-fhirpath/system" + "github.com/google/go-cmp/cmp" +) + +func TestParseBoolean(t *testing.T) { + testCases := []struct { + input string + want system.Boolean + }{ + {"false", false}, + {"true", true}, + } + + for _, tc := range testCases { + t.Run(tc.input, func(t *testing.T) { + got, err := system.ParseBoolean(tc.input) + if err != nil { + t.Fatalf("ParseBoolean() = %v; want nil", err) + } + + if got, want := got, tc.want; got != want { + t.Errorf("ParseBoolean() = %v; want %v", got, want) + } + + }) + } +} + +func TestParseBoolean_InvalidString_ReturnsParseError(t *testing.T) { + testCases := []struct { + input string + }{ + {"bad value"}, + {"b"}, + {"TRUE"}, + {"1"}, + {"FALSE"}, + {"0"}, + {"False"}, + {"True"}, + } + + for _, tc := range testCases { + t.Run(tc.input, func(t *testing.T) { + _, err := system.ParseBoolean(tc.input) + + var parseErr *system.ParseError + ok := errors.As(err, &parseErr) + + if got, want := ok, true; got != want { + t.Errorf("ParseBoolean() = %v; want %v", got, want) + } + }) + } +} + +func TestMustParseBoolean(t *testing.T) { + testCases := []struct { + input string + want system.Boolean + }{ + {"false", false}, + {"true", true}, + } + + for _, tc := range testCases { + t.Run(tc.input, func(t *testing.T) { + got := system.MustParseBoolean(tc.input) + + if got, want := got, tc.want; got != want { + t.Errorf("ParseBoolean() = %v; want %v", got, want) + } + }) + } +} + +func TestMustParseBoolean_InvalidString_Panics(t *testing.T) { + testCases := []struct { + input string + }{ + {"bad value"}, + {"b"}, + {"TRUE"}, + {"1"}, + {"FALSE"}, + {"0"}, + {"False"}, + {"True"}, + } + + for _, tc := range testCases { + t.Run(tc.input, func(t *testing.T) { + defer func() { _ = recover() }() + + system.MustParseBoolean(tc.input) + + t.Errorf("MustParseBoolean() = want panic") + }) + } +} + +func TestBooleanBool(t *testing.T) { + testCases := []struct { + input string + want bool + }{ + {"false", false}, + {"true", true}, + } + + for _, tc := range testCases { + t.Run(tc.input, func(t *testing.T) { + v := system.MustParseBoolean(tc.input) + + got := v.Bool() + + if got, want := got, tc.want; got != want { + t.Errorf("Boolean.Bool() = %v; want %v", got, want) + } + }) + } +} + +func TestBooleanR4(t *testing.T) { + testCases := []struct { + input string + want *fhir.Boolean + }{ + {"false", &fhir.Boolean{Value: false}}, + {"true", &fhir.Boolean{Value: true}}, + } + + for _, tc := range testCases { + t.Run(tc.input, func(t *testing.T) { + v := system.MustParseBoolean(tc.input) + + got := v.R4() + + if diff := cmp.Diff(got, tc.want); diff != "" { + t.Errorf("Boolean.ToBoolean() mismatch (-got +want):\n%s", diff) + } + }) + } +} diff --git a/system/conv.go b/system/conv.go new file mode 100644 index 0000000..0cd39da --- /dev/null +++ b/system/conv.go @@ -0,0 +1,41 @@ +package system + +import ( + "fmt" + + fhir "github.com/friendly-fhir/go-fhir/r4/core" + profile "github.com/friendly-fhir/go-fhir/r4/core/profiles" +) + +// FromR4 return a system type from a (valid) FHIR R4 element. +func FromR4(element fhir.Element) (Any, error) { + switch e := element.(type) { + case *fhir.Boolean: + var b Boolean + b.FromR4(e) + return b, nil + case profile.Integer: + var i Integer + i.FromR4(e) + return i, nil + case profile.String: + var s String + s.FromR4(e) + return s, nil + } + return nil, fmt.Errorf("%w: %T is not a valid R4 type", ErrNotConvertible, element) +} + +// Normalizes a FHIR R4 type into a system type, if able -- or just returns +// the input value if it's not a FHIR R4 type. +func Normalize(v any) any { + element, ok := v.(fhir.Element) + if !ok { + return v + } + got, err := FromR4(element) + if err != nil { + return element + } + return got +} diff --git a/system/doc.go b/system/doc.go new file mode 100644 index 0000000..3dae947 --- /dev/null +++ b/system/doc.go @@ -0,0 +1,21 @@ +/* +Package system contains the definitions of FHIRPath "system"-namespace types. +This includes all operations for parsing, converting to/from, and comparing +these types: + - Boolean: A boolean type + - Integer: A 32-bit integer type + - Decimal: A decimal type with precision equivalent to an IEEE double-precision + floating-point value. + - Quantity: A numerical type that also contains a unit value + - String: A type capable of holding a UTF-8 sequence of characters + - Date: A representation of a (possibly partial) Date value + - Time: A representation of a wall-clock time, disconnected from a date + - DateTime: A (possibly partial) representation of a moment in time + +Due to some of the weirder requirements of FHIRPath regarding comparison +semantics of certain types, some of these types define a `TryEqual` or `TryCompare` +instead of `Equal` or `Compare`, since in FHIRPath some operations may +optionally _not_ yield a result at all. This is all abstracted in the top-level +equality and comparison functions. +*/ +package system diff --git a/system/errors.go b/system/errors.go new file mode 100644 index 0000000..756668e --- /dev/null +++ b/system/errors.go @@ -0,0 +1,43 @@ +package system + +import ( + "fmt" + "reflect" + "strings" +) + +var ( + // ErrNotConvertible is an error raised when attempting to call Collection.To* + // to a type that is not convertible. + ErrNotConvertible = fmt.Errorf("not convertible") +) + +// ParseError is returned from objects during failed parsing. +// This error optionally contains an underlying error reason that may propagate +// failures caused by internal system APIs; the type and value of which is not +// guaranteed to be stable, but may be used for logging purposes. +type ParseError struct { + Type reflect.Type + Input string + Reason error +} + +func (e *ParseError) Error() string { + name := e.Type.Name() + input := e.Input + return fmt.Sprintf( + "%v parse '%v': %v", + strings.ToLower(name), + input, + e.Reason, + ) +} + +func newParseError[T Any](input string, reason error) error { + var v T + return &ParseError{ + Type: reflect.TypeOf(v), + Input: input, + Reason: reason, + } +} diff --git a/system/integer.go b/system/integer.go new file mode 100644 index 0000000..649f918 --- /dev/null +++ b/system/integer.go @@ -0,0 +1,134 @@ +package system + +import ( + "encoding" + "encoding/json" + "fmt" + "strconv" + + fhir "github.com/friendly-fhir/go-fhir/r4/core" + profile "github.com/friendly-fhir/go-fhir/r4/core/profiles" +) + +// Integer is the Go-representation of the FHIRPath System.Integer type. This is +// a 32-bit signed integer value. +type Integer int32 + +// NewInteger constructs a new System.Integer object with the given value. +func NewInteger(value int32) Integer { + return Integer(value) +} + +// ParseInteger parses a string into the valid FHIRPath System.Integer type. +func ParseInteger(str string) (Integer, error) { + value, err := strconv.ParseInt(str, 10, 32) + if err != nil { + return Integer(0), newParseError[Integer](str, err) + } + + return Integer(value), nil +} + +// MustParseInteger parses an integer string, and panics if the value is invalid. +func MustParseInteger(str string) Integer { + got, err := ParseInteger(str) + if err != nil { + panic(err) + } + return got +} + +func (Integer) isAny() {} + +// Negate returns the inverse polarity of this integer value. +func (i Integer) Negate() Integer { + return -i +} + +// Compare compares two integer values. +// This returns a negative value if this object is less than other, +// a positive number if other is greater than this, or equal if both values are +// the same. +// +// For example: +// +// a, b := system.Integer(0), system.Integer(42) +// assert.True(a.Compare(b) < 0) +// assert.True(b.Compare(a) > 0) +// assert.True(a.Compare(a) == 0) +func (i Integer) Compare(other Integer) int { + return int(i - other) +} + +// Int32 converts this system.Integer into an in32Go native type. +func (i Integer) Int32() int32 { + return int32(i) +} + +// Formatter + +// String returns the string representation of the System.Integer. +func (i Integer) String() string { + return strconv.Itoa(int(i)) +} + +// Format formats this System.Integer with the given format and arguments. +func (i Integer) Format(state fmt.State, verb rune) { + fmt.Fprintf(state, "%"+string(verb), int32(i)) +} + +var ( + _ fmt.Stringer = (*Integer)(nil) + _ fmt.Formatter = (*Integer)(nil) +) + +// R4 Conversions + +// FromR4 converts a FHIR Integer type into a System.Integer type. +func (i *Integer) FromR4(in profile.Integer) { + *i = Integer(in.GetValue()) +} + +// R4 converts this System.Integer into a FHIR Integer type. +func (i *Integer) R4() *fhir.Integer { + return &fhir.Integer{Value: int32(*i)} +} + +// JSON conversions + +// MarshalJSON converts this Integer object into a JSON object. +func (i Integer) MarshalJSON() ([]byte, error) { + return json.Marshal(int32(i)) +} + +// UnmarshalJSON converts a JSON object into an Integer object. +func (i *Integer) UnmarshalJSON(data []byte) error { + var value int32 + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *i = Integer(value) + return nil +} + +var ( + _ json.Marshaler = (*Integer)(nil) + _ json.Unmarshaler = (*Integer)(nil) +) + +// Text conversions + +// MarshalText converts this Integer object into a text object. +func (i Integer) MarshalText() ([]byte, error) { + return i.MarshalJSON() +} + +// UnmarshalText converts a text object into an Integer object. +func (i *Integer) UnmarshalText(text []byte) error { + return i.UnmarshalJSON(text) +} + +var ( + _ encoding.TextMarshaler = (*Integer)(nil) + _ encoding.TextUnmarshaler = (*Integer)(nil) +) diff --git a/system/integer_test.go b/system/integer_test.go new file mode 100644 index 0000000..81034fc --- /dev/null +++ b/system/integer_test.go @@ -0,0 +1,145 @@ +package system_test + +import ( + "errors" + "testing" + + fhir "github.com/friendly-fhir/go-fhir/r4/core" + "github.com/friendly-fhir/go-fhirpath/system" + "github.com/google/go-cmp/cmp" +) + +func TestParseInteger(t *testing.T) { + testCases := []struct { + input string + want system.Integer + }{ + {"0", 0}, + {"1234", 1234}, + {"-74", -74}, + } + + for _, tc := range testCases { + t.Run(tc.input, func(t *testing.T) { + got, err := system.ParseInteger(tc.input) + + if err != nil { + t.Fatalf("ParseInteger() = %v; want nil", err) + } + + if got, want := got, tc.want; got != want { + t.Errorf("ParseInteger() = %v; want %v", got, want) + } + }) + } +} + +func TestParseInteger_InvalidString_ReturnsParseError(t *testing.T) { + testCases := []struct { + input string + }{ + {"bad value"}, + {"0x1234"}, + {"v12345v"}, + } + + for _, tc := range testCases { + t.Run(tc.input, func(t *testing.T) { + _, err := system.ParseInteger(tc.input) + + var parseErr *system.ParseError + ok := errors.As(err, &parseErr) + + if got, want := ok, true; got != want { + t.Errorf("ParseInteger() = %v; want %v", got, want) + } + }) + } +} + +func TestMustParseInteger(t *testing.T) { + testCases := []struct { + input string + want system.Integer + }{ + {"0", 0}, + {"1234", 1234}, + {"-74", -74}, + } + + for _, tc := range testCases { + t.Run(tc.input, func(t *testing.T) { + got := system.MustParseInteger(tc.input) + + if got, want := got, tc.want; got != want { + t.Errorf("MustParseInteger() = %v; want %v", got, want) + } + }) + } +} + +func TestMustParseInteger_InvalidString_Panics(t *testing.T) { + testCases := []struct { + input string + }{ + {"bad value"}, + {"0x1234"}, + {"v12345v"}, + } + + for _, tc := range testCases { + t.Run(tc.input, func(t *testing.T) { + defer func() { _ = recover() }() + + system.MustParseInteger(tc.input) + + t.Errorf("MustParseInteger() did not panic") + }) + } +} + +func TestIntegerInt32(t *testing.T) { + testCases := []struct { + input string + want int32 + }{ + {"0", 0}, + {"1234", 1234}, + {"-74", -74}, + } + + for _, tc := range testCases { + t.Run(tc.input, func(t *testing.T) { + v := system.MustParseInteger(tc.input) + + got := v.Int32() + + if got, want := got, tc.want; got != want { + t.Errorf("Integer.Int32() = %v; want %v", got, want) + } + }) + } +} + +func TestIntegerR4(t *testing.T) { + testCases := []struct { + input string + want *fhir.Integer + }{ + {"0", &fhir.Integer{Value: 0}}, + {"1234", &fhir.Integer{Value: 1234}}, + {"-74", &fhir.Integer{Value: -74}}, + } + + for _, tc := range testCases { + t.Run(tc.input, func(t *testing.T) { + v := system.MustParseInteger(tc.input) + + got := v.R4() + + if diff := cmp.Diff(got, tc.want); diff != "" { + t.Errorf("Integer.R4() mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/system/string.go b/system/string.go new file mode 100644 index 0000000..68a27cb --- /dev/null +++ b/system/string.go @@ -0,0 +1,148 @@ +package system + +import ( + "encoding" + "encoding/json" + "fmt" + "strings" + + fhir "github.com/friendly-fhir/go-fhir/r4/core" + profile "github.com/friendly-fhir/go-fhir/r4/core/profiles" + "github.com/friendly-fhir/go-fhirpath/internal/esc" +) + +type String string + +// NewString constructs a new System.String object with the given value. +func NewString(format string, args ...any) String { + return String(fmt.Sprintf(format, args...)) +} + +// ParseString parses a string from a FHIRPath string literal. This will translate +// any embedded escapes into the associated values. +func ParseString(str string) (String, error) { + result, ok := strings.CutPrefix(str, "'") + if !ok { + return "", newParseError[String](str, fmt.Errorf("missing prefix quote")) + } + + result, ok = strings.CutSuffix(result, "'") + if !ok { + return "", newParseError[String](str, fmt.Errorf("missing suffix quote")) + } + + result, err := esc.Parse(result) + if err != nil { + return "", newParseError[String](str, err) + } + + return String(result), nil +} + +// MustParseString parses a string from a FHIRPath string literal, and panics if +// the value is invalid. +func MustParseString(str string) String { + result, err := ParseString(str) + if err != nil { + panic(err) + } + return result +} + +func (String) isAny() {} + +// Compares compares the other system.String value to provide a total-ordering. +func (s String) Compare(other String) int { + return strings.Compare(string(s), string(other)) +} + +// Equivalent compares two System.String values for FHIRPath equivalence. +// +// This will ignore casing and normalize whitespace. +func (s String) Equivalent(other String) bool { + return s.normalize() == other.normalize() +} + +func (s String) normalize() string { + result := strings.ToLower(string(s)) + result = whitespaceNormalizer.Replace(result) + return result +} + +// Formatting + +// String returns the Go string representation of this System.String. +func (s String) String() string { + return string(s) +} + +// Format formats this System.String with the given format and arguments. +func (s String) Format(state fmt.State, verb rune) { + fmt.Fprintf(state, "%"+string(verb), string(s)) +} + +var ( + _ fmt.Stringer = (*String)(nil) + _ fmt.Formatter = (*String)(nil) +) + +// R4 conversions + +// R4 converts this System.String into a FHIR String type. +func (s String) R4() *fhir.String { + return &fhir.String{Value: string(s)} +} + +// FromR4 converts a FHIR String type into a System.String type. +func (s *String) FromR4(r profile.String) { + *s = String(r.GetValue()) +} + +// Marshal conversions + +// MarshalJSON converts this String object into a JSON object. +func (s String) MarshalJSON() ([]byte, error) { + return json.Marshal(string(s)) +} + +// UnmarshalJSON converts a JSON object into a String object. +func (s *String) UnmarshalJSON(data []byte) error { + var value string + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *s = String(value) + return nil +} + +var ( + _ json.Marshaler = (*String)(nil) + _ json.Unmarshaler = (*String)(nil) +) + +// Text conversions + +// MarshalText converts this String object into a text object. +func (s String) MarshalText() ([]byte, error) { + return s.MarshalJSON() +} + +// UnmarshalText converts a text object into a String object. +func (s *String) UnmarshalText(text []byte) error { + return s.UnmarshalJSON(text) +} + +var ( + _ encoding.TextMarshaler = (*String)(nil) + _ encoding.TextUnmarshaler = (*String)(nil) +) + +var whitespaceNormalizer *strings.Replacer + +func init() { + whitespaceNormalizer = strings.NewReplacer( + "\t", " ", + "\n", " ", + "\r", " ", + ) +} diff --git a/system/string_test.go b/system/string_test.go new file mode 100644 index 0000000..a6ddbb5 --- /dev/null +++ b/system/string_test.go @@ -0,0 +1,133 @@ +package system_test + +import ( + "errors" + "testing" + + "github.com/friendly-fhir/go-fhirpath/system" +) + +type cmpResult func(int) bool + +var ( + less cmpResult = func(v int) bool { + return v < 0 + } + greater cmpResult = func(v int) bool { + return v > 0 + } + equal cmpResult = func(v int) bool { + return v == 0 + } +) + +func TestParseString(t *testing.T) { + testCases := []struct { + name string + input string + want system.String + }{ + {"ASCII", "'hello world'", "hello world"}, + {"Form Feed", `'\f'`, "\f"}, + {"Newline", `'\n'`, "\n"}, + {"Carriage Return", `'\r'`, "\r"}, + {"Single-quote", `'\''`, `'`}, + {"Double-quote", `'\"'`, `"`}, + {"Tab", `'\t'`, "\t"}, + {"Backtick", "'\\`'", "`"}, + {"Backslash", `'\\'`, `\`}, + {"Double-escape Form Feed", `'\\f'`, `\f`}, + {"Double-escape Newline", `'\\n'`, `\n`}, + {"Double-escape Carriage Return", `'\\r'`, `\r`}, + {"Double-escape Single-quote", `'\\''`, `\'`}, + {"Double-escape Double-quote", `'\\"'`, `\"`}, + {"Double-escape Tab", `'\\t'`, `\t`}, + {"Double-escape Backtick", "'\\\\`'", "\\`"}, + {"Double-escape Backslash", `'\\\\'`, `\\`}, + {"UTF-8-1", `'\u044d\u0442\u043e'`, `это`}, + {"UTF-8-2", `'\u0442\u0435\u0441\u0442'`, `тест`}, + {"UTF-8-3", `'\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435'`, `сообщение`}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got, err := system.ParseString(tc.input) + if err != nil { + t.Fatalf("ParseString() = %v; want nil", err) + } + + if got != tc.want { + t.Errorf("ParseString() = %v; want %v", got, tc.want) + } + }) + } +} + +func TestParseString_BadInput_ReturnsParseError(t *testing.T) { + testCases := []struct { + name string + input string + }{ + {"Missing suffix quote", "'hello"}, + {"Missing prefix quote", "world'"}, + {"Missing both quotes", "hello world"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, err := system.ParseString(tc.input) + + var parseErr *system.ParseError + ok := errors.As(err, &parseErr) + + if got, want := ok, true; got != want { + t.Errorf("ParseString() = %v; want %v", got, want) + } + }) + } +} + +func TestStringCompare(t *testing.T) { + testCases := []struct { + name string + lhs, rhs system.String + cmp cmpResult + }{ + {"Equal", "hello", "hello", equal}, + {"Less", "123", "987", less}, + {"Greater", "987", "123", greater}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got := tc.lhs.Compare(tc.rhs) + + ok := tc.cmp(got) + + if got, want := ok, true; got != want { + t.Errorf("String.Compare() = %v; want %v", got, want) + } + }) + } +} + +func TestStringEquivalent(t *testing.T) { + testCases := []struct { + name string + lhs, rhs system.String + }{ + {"Equal", "hello", "hello"}, + {"Different Case", "HELLO", "hello"}, + {"Normalized Whitespace", "\t\r\nHELLO ", " hello "}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got := tc.lhs.Equivalent(tc.rhs) + + if got, want := got, true; got != want { + t.Errorf("String.Equivalent() = %v; want %v", got, want) + } + }) + } +}