From 54d5dbd04aa138a8bb5b30bd3a5f1af99491b04f Mon Sep 17 00:00:00 2001 From: "Dr. Carsten Leue" Date: Fri, 7 Nov 2025 17:31:27 +0100 Subject: [PATCH] fix: more tests for iso and prism Signed-off-by: Dr. Carsten Leue --- v2/cli/lens.go | 27 +- v2/cli/lens_test.go | 100 ++++- v2/optics/iso/isos.go | 187 +++++++++ v2/optics/iso/isos_test.go | 432 +++++++++++++++++++ v2/optics/iso/option/isos.go | 83 ++++ v2/optics/iso/option/isos_test.go | 366 ++++++++++++++++ v2/optics/prism/prisms.go | 347 +++++++++++++++ v2/optics/prism/prisms_test.go | 534 ++++++++++++++++++++++++ v2/samples/lens/{gen.go => gen_lens.go} | 54 +-- 9 files changed, 2088 insertions(+), 42 deletions(-) create mode 100644 v2/optics/iso/isos.go create mode 100644 v2/optics/iso/isos_test.go create mode 100644 v2/optics/iso/option/isos.go create mode 100644 v2/optics/iso/option/isos_test.go create mode 100644 v2/optics/prism/prisms_test.go rename v2/samples/lens/{gen.go => gen_lens.go} (79%) diff --git a/v2/cli/lens.go b/v2/cli/lens.go index a1a4c55..b322723 100644 --- a/v2/cli/lens.go +++ b/v2/cli/lens.go @@ -63,7 +63,7 @@ type fieldInfo struct { Name string TypeName string BaseType string // TypeName without leading * for pointer types - IsOptional bool // true if json tag has omitempty or field is a pointer + IsOptional bool // true if field is a pointer or has json omitempty tag } // templateData holds data for template rendering @@ -93,15 +93,15 @@ const lensConstructorTemplate = ` func Make{{.Name}}Lenses() {{.Name}}Lenses { {{- range .Fields}} {{- if .IsOptional}} - getOrElse{{.Name}} := O.GetOrElse(F.ConstNil[{{.BaseType}}]) + iso{{.Name}} := I.FromZero[{{.TypeName}}]() {{- end}} {{- end}} return {{.Name}}Lenses{ {{- range .Fields}} {{- if .IsOptional}} {{.Name}}: L.MakeLens( - func(s {{$.Name}}) O.Option[{{.TypeName}}] { return O.FromNillable(s.{{.Name}}) }, - func(s {{$.Name}}, v O.Option[{{.TypeName}}]) {{$.Name}} { s.{{.Name}} = getOrElse{{.Name}}(v); return s }, + func(s {{$.Name}}) O.Option[{{.TypeName}}] { return iso{{.Name}}.Get(s.{{.Name}}) }, + func(s {{$.Name}}, v O.Option[{{.TypeName}}]) {{$.Name}} { s.{{.Name}} = iso{{.Name}}.ReverseGet(v); return s }, ), {{- else}} {{.Name}}: L.MakeLens( @@ -117,15 +117,15 @@ func Make{{.Name}}Lenses() {{.Name}}Lenses { func Make{{.Name}}RefLenses() {{.Name}}RefLenses { {{- range .Fields}} {{- if .IsOptional}} - getOrElse{{.Name}} := O.GetOrElse(F.ConstNil[{{.BaseType}}]) + iso{{.Name}} := I.FromZero[{{.TypeName}}]() {{- end}} {{- end}} return {{.Name}}RefLenses{ {{- range .Fields}} {{- if .IsOptional}} {{.Name}}: L.MakeLensRef( - func(s *{{$.Name}}) O.Option[{{.TypeName}}] { return O.FromNillable(s.{{.Name}}) }, - func(s *{{$.Name}}, v O.Option[{{.TypeName}}]) *{{$.Name}} { s.{{.Name}} = getOrElse{{.Name}}(v); return s }, + func(s *{{$.Name}}) O.Option[{{.TypeName}}] { return iso{{.Name}}.Get(s.{{.Name}}) }, + func(s *{{$.Name}}, v O.Option[{{.TypeName}}]) *{{$.Name}} { s.{{.Name}} = iso{{.Name}}.ReverseGet(v); return s }, ), {{- else}} {{.Name}}: L.MakeLensRef( @@ -332,11 +332,16 @@ func parseFile(filename string) ([]structInfo, string, error) { isOptional := false baseType := typeName - // Only pointer types can be optional + // Check if field is optional: + // 1. Pointer types are always optional + // 2. Non-pointer types with json omitempty tag are optional if isPointerType(field.Type) { isOptional = true // Strip leading * for base type baseType = strings.TrimPrefix(typeName, "*") + } else if hasOmitEmpty(field.Tag) { + // Non-pointer type with omitempty is also optional + isOptional = true } // Extract imports from this field's type @@ -462,10 +467,10 @@ func generateLensHelpers(dir, filename string, verbose bool) error { // Write imports f.WriteString("import (\n") // Standard fp-go imports always needed - f.WriteString("\tF \"github.com/IBM/fp-go/v2/function\"\n") f.WriteString("\tL \"github.com/IBM/fp-go/v2/optics/lens\"\n") f.WriteString("\tLO \"github.com/IBM/fp-go/v2/optics/lens/option\"\n") f.WriteString("\tO \"github.com/IBM/fp-go/v2/option\"\n") + f.WriteString("\tI \"github.com/IBM/fp-go/v2/optics/iso/option\"\n") // Add additional imports collected from field types for importPath, alias := range allImports { @@ -502,7 +507,7 @@ func LensCommand() *C.Command { return &C.Command{ Name: "lens", Usage: "generate lens code for annotated structs", - Description: "Scans Go files for structs annotated with 'fp-go:Lens' and generates lens types. Fields with json omitempty tag or pointer types generate LensO (optional lens).", + Description: "Scans Go files for structs annotated with 'fp-go:Lens' and generates lens types. Pointer types and non-pointer types with json omitempty tag generate LensO (optional lens).", Flags: []C.Flag{ flagLensDir, flagFilename, @@ -517,5 +522,3 @@ func LensCommand() *C.Command { }, } } - -// Made with Bob diff --git a/v2/cli/lens_test.go b/v2/cli/lens_test.go index 2cf6a99..7eb6eae 100644 --- a/v2/cli/lens_test.go +++ b/v2/cli/lens_test.go @@ -278,6 +278,65 @@ type Other struct { assert.Equal(t, "City", address.Fields[1].Name) } +func TestParseFileWithOmitEmpty(t *testing.T) { + // Create a temporary test file + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.go") + + testCode := `package testpkg + +// fp-go:Lens +type Config struct { + Name string + Value string ` + "`json:\"value,omitempty\"`" + ` + Count int ` + "`json:\",omitempty\"`" + ` + Optional *string ` + "`json:\"optional,omitempty\"`" + ` + Required int ` + "`json:\"required\"`" + ` +} +` + + err := os.WriteFile(testFile, []byte(testCode), 0644) + require.NoError(t, err) + + // Parse the file + structs, pkg, err := parseFile(testFile) + require.NoError(t, err) + + // Verify results + assert.Equal(t, "testpkg", pkg) + assert.Len(t, structs, 1) + + // Check Config struct + config := structs[0] + assert.Equal(t, "Config", config.Name) + assert.Len(t, config.Fields, 5) + + // Name - no tag, not optional + assert.Equal(t, "Name", config.Fields[0].Name) + assert.Equal(t, "string", config.Fields[0].TypeName) + assert.False(t, config.Fields[0].IsOptional) + + // Value - has omitempty, should be optional + assert.Equal(t, "Value", config.Fields[1].Name) + assert.Equal(t, "string", config.Fields[1].TypeName) + assert.True(t, config.Fields[1].IsOptional, "Value field with omitempty should be optional") + + // Count - has omitempty (no field name in tag), should be optional + assert.Equal(t, "Count", config.Fields[2].Name) + assert.Equal(t, "int", config.Fields[2].TypeName) + assert.True(t, config.Fields[2].IsOptional, "Count field with omitempty should be optional") + + // Optional - pointer with omitempty, should be optional + assert.Equal(t, "Optional", config.Fields[3].Name) + assert.Equal(t, "*string", config.Fields[3].TypeName) + assert.True(t, config.Fields[3].IsOptional) + + // Required - has json tag but no omitempty, not optional + assert.Equal(t, "Required", config.Fields[4].Name) + assert.Equal(t, "int", config.Fields[4].TypeName) + assert.False(t, config.Fields[4].IsOptional, "Required field without omitempty should not be optional") +} + func TestGenerateLensHelpers(t *testing.T) { // Create a temporary directory with test files tmpDir := t.TempDir() @@ -318,8 +377,7 @@ type TestStruct struct { assert.Contains(t, contentStr, "MakeTestStructLens") assert.Contains(t, contentStr, "L.Lens[TestStruct, string]") assert.Contains(t, contentStr, "LO.LensO[TestStruct, *int]") - assert.Contains(t, contentStr, "O.FromNillable") - assert.Contains(t, contentStr, "O.GetOrElse") + assert.Contains(t, contentStr, "I.FromZero") } func TestGenerateLensHelpersNoAnnotations(t *testing.T) { @@ -378,8 +436,42 @@ func TestLensTemplates(t *testing.T) { assert.Contains(t, constructorStr, "return TestStructLenses{") assert.Contains(t, constructorStr, "Name: L.MakeLens(") assert.Contains(t, constructorStr, "Value: L.MakeLens(") - assert.Contains(t, constructorStr, "O.FromNillable") - assert.Contains(t, constructorStr, "O.GetOrElse") + assert.Contains(t, constructorStr, "I.FromZero") +} + +func TestLensTemplatesWithOmitEmpty(t *testing.T) { + s := structInfo{ + Name: "ConfigStruct", + Fields: []fieldInfo{ + {Name: "Name", TypeName: "string", IsOptional: false}, + {Name: "Value", TypeName: "string", IsOptional: true}, // non-pointer with omitempty + {Name: "Count", TypeName: "int", IsOptional: true}, // non-pointer with omitempty + {Name: "Pointer", TypeName: "*string", IsOptional: true}, // pointer + }, + } + + // Test struct template + var structBuf bytes.Buffer + err := structTmpl.Execute(&structBuf, s) + require.NoError(t, err) + + structStr := structBuf.String() + assert.Contains(t, structStr, "type ConfigStructLenses struct") + assert.Contains(t, structStr, "Name L.Lens[ConfigStruct, string]") + assert.Contains(t, structStr, "Value LO.LensO[ConfigStruct, string]", "non-pointer with omitempty should use LensO") + assert.Contains(t, structStr, "Count LO.LensO[ConfigStruct, int]", "non-pointer with omitempty should use LensO") + assert.Contains(t, structStr, "Pointer LO.LensO[ConfigStruct, *string]") + + // Test constructor template + var constructorBuf bytes.Buffer + err = constructorTmpl.Execute(&constructorBuf, s) + require.NoError(t, err) + + constructorStr := constructorBuf.String() + assert.Contains(t, constructorStr, "func MakeConfigStructLenses() ConfigStructLenses") + assert.Contains(t, constructorStr, "isoValue := I.FromZero[string]()") + assert.Contains(t, constructorStr, "isoCount := I.FromZero[int]()") + assert.Contains(t, constructorStr, "isoPointer := I.FromZero[*string]()") } func TestLensCommandFlags(t *testing.T) { diff --git a/v2/optics/iso/isos.go b/v2/optics/iso/isos.go new file mode 100644 index 0000000..1b17b47 --- /dev/null +++ b/v2/optics/iso/isos.go @@ -0,0 +1,187 @@ +// Copyright (c) 2023 - 2025 IBM Corp. +// All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package iso + +import ( + "strings" + "time" + + B "github.com/IBM/fp-go/v2/bytes" + F "github.com/IBM/fp-go/v2/function" + S "github.com/IBM/fp-go/v2/string" +) + +// UTF8String creates an isomorphism between byte slices and UTF-8 strings. +// This isomorphism provides bidirectional conversion between []byte and string, +// treating the byte slice as UTF-8 encoded text. +// +// Returns: +// - An Iso[[]byte, string] where: +// - Get: Converts []byte to string using UTF-8 encoding +// - ReverseGet: Converts string to []byte using UTF-8 encoding +// +// Behavior: +// - Get direction: Interprets the byte slice as UTF-8 and returns the corresponding string +// - ReverseGet direction: Encodes the string as UTF-8 bytes +// +// Example: +// +// iso := UTF8String() +// +// // Convert bytes to string +// str := iso.Get([]byte("hello")) // "hello" +// +// // Convert string to bytes +// bytes := iso.ReverseGet("world") // []byte("world") +// +// // Round-trip conversion +// original := []byte("test") +// result := iso.ReverseGet(iso.Get(original)) // []byte("test") +// +// Use cases: +// - Converting between string and byte representations +// - Working with APIs that use different text representations +// - File I/O operations where you need to switch between strings and bytes +// - Network protocols that work with byte streams +// +// Note: This isomorphism assumes valid UTF-8 encoding. Invalid UTF-8 sequences +// in the byte slice will be handled according to Go's string conversion rules +// (typically replaced with the Unicode replacement character U+FFFD). +func UTF8String() Iso[[]byte, string] { + return MakeIso(B.ToString, S.ToBytes) +} + +// lines creates an isomorphism between a slice of strings and a single string +// with lines separated by the specified separator. +// This is an internal helper function used by Lines. +// +// Parameters: +// - sep: The separator string to use for joining/splitting lines +// +// Returns: +// - An Iso[[]string, string] that joins/splits strings using the separator +// +// Behavior: +// - Get direction: Joins the string slice into a single string with separators +// - ReverseGet direction: Splits the string by the separator into a slice +func lines(sep string) Iso[[]string, string] { + return MakeIso(S.Join(sep), F.Bind2nd(strings.Split, sep)) +} + +// Lines creates an isomorphism between a slice of strings and a single string +// with newline-separated lines. +// This is useful for working with multi-line text where you need to convert +// between a single string and individual lines. +// +// Returns: +// - An Iso[[]string, string] where: +// - Get: Joins string slice with newline characters ("\n") +// - ReverseGet: Splits string by newline characters into a slice +// +// Behavior: +// - Get direction: Joins each string in the slice with "\n" separator +// - ReverseGet direction: Splits the string at each "\n" into a slice +// +// Example: +// +// iso := Lines() +// +// // Convert lines to single string +// lines := []string{"line1", "line2", "line3"} +// text := iso.Get(lines) // "line1\nline2\nline3" +// +// // Convert string to lines +// text := "hello\nworld" +// lines := iso.ReverseGet(text) // []string{"hello", "world"} +// +// // Round-trip conversion +// original := []string{"a", "b", "c"} +// result := iso.ReverseGet(iso.Get(original)) // []string{"a", "b", "c"} +// +// Use cases: +// - Processing multi-line text files +// - Converting between text editor representations (array of lines vs single string) +// - Working with configuration files that have line-based structure +// - Parsing or generating multi-line output +// +// Note: Empty strings in the slice will result in consecutive newlines in the output. +// Splitting a string with trailing newlines will include an empty string at the end. +// +// Example with edge cases: +// +// iso := Lines() +// lines := []string{"a", "", "b"} +// text := iso.Get(lines) // "a\n\nb" +// result := iso.ReverseGet(text) // []string{"a", "", "b"} +// +// text := "a\nb\n" +// lines := iso.ReverseGet(text) // []string{"a", "b", ""} +func Lines() Iso[[]string, string] { + return lines("\n") +} + +// UnixMilli creates an isomorphism between Unix millisecond timestamps and time.Time values. +// This isomorphism provides bidirectional conversion between int64 milliseconds since +// the Unix epoch (January 1, 1970 UTC) and Go's time.Time type. +// +// Returns: +// - An Iso[int64, time.Time] where: +// - Get: Converts Unix milliseconds (int64) to time.Time +// - ReverseGet: Converts time.Time to Unix milliseconds (int64) +// +// Behavior: +// - Get direction: Creates a time.Time from milliseconds since Unix epoch +// - ReverseGet direction: Extracts milliseconds since Unix epoch from time.Time +// +// Example: +// +// iso := UnixMilli() +// +// // Convert milliseconds to time.Time +// millis := int64(1609459200000) // 2021-01-01 00:00:00 UTC +// t := iso.Get(millis) +// +// // Convert time.Time to milliseconds +// now := time.Now() +// millis := iso.ReverseGet(now) +// +// // Round-trip conversion +// original := int64(1234567890000) +// result := iso.ReverseGet(iso.Get(original)) // 1234567890000 +// +// Use cases: +// - Working with APIs that use Unix millisecond timestamps (e.g., JavaScript Date.now()) +// - Database storage where timestamps are stored as integers +// - JSON serialization/deserialization of timestamps +// - Converting between different time representations in distributed systems +// +// Precision notes: +// - Millisecond precision is maintained in both directions +// - Sub-millisecond precision in time.Time is lost when converting to int64 +// - The conversion is timezone-aware (time.Time includes location information) +// +// Example with precision: +// +// iso := UnixMilli() +// t := time.Date(2021, 1, 1, 12, 30, 45, 123456789, time.UTC) +// millis := iso.ReverseGet(t) // Nanoseconds are truncated to milliseconds +// restored := iso.Get(millis) // Nanoseconds will be 123000000 +// +// Note: This isomorphism uses UTC for the time.Time values. If you need to preserve +// timezone information, consider storing it separately or using a different representation. +func UnixMilli() Iso[int64, time.Time] { + return MakeIso(time.UnixMilli, time.Time.UnixMilli) +} diff --git a/v2/optics/iso/isos_test.go b/v2/optics/iso/isos_test.go new file mode 100644 index 0000000..e356451 --- /dev/null +++ b/v2/optics/iso/isos_test.go @@ -0,0 +1,432 @@ +// Copyright (c) 2023 - 2025 IBM Corp. +// All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package iso + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// TestUTF8String tests the UTF8String isomorphism +func TestUTF8String(t *testing.T) { + iso := UTF8String() + + t.Run("Get converts bytes to string", func(t *testing.T) { + bytes := []byte("hello world") + result := iso.Get(bytes) + assert.Equal(t, "hello world", result) + }) + + t.Run("Get handles empty bytes", func(t *testing.T) { + bytes := []byte{} + result := iso.Get(bytes) + assert.Equal(t, "", result) + }) + + t.Run("Get handles UTF-8 characters", func(t *testing.T) { + bytes := []byte("Hello δΈ–η•Œ 🌍") + result := iso.Get(bytes) + assert.Equal(t, "Hello δΈ–η•Œ 🌍", result) + }) + + t.Run("ReverseGet converts string to bytes", func(t *testing.T) { + str := "hello world" + result := iso.ReverseGet(str) + assert.Equal(t, []byte("hello world"), result) + }) + + t.Run("ReverseGet handles empty string", func(t *testing.T) { + str := "" + result := iso.ReverseGet(str) + assert.Equal(t, []byte{}, result) + }) + + t.Run("ReverseGet handles UTF-8 characters", func(t *testing.T) { + str := "Hello δΈ–η•Œ 🌍" + result := iso.ReverseGet(str) + assert.Equal(t, []byte("Hello δΈ–η•Œ 🌍"), result) + }) + + t.Run("Round-trip bytes to string to bytes", func(t *testing.T) { + original := []byte("test data") + result := iso.ReverseGet(iso.Get(original)) + assert.Equal(t, original, result) + }) + + t.Run("Round-trip string to bytes to string", func(t *testing.T) { + original := "test string" + result := iso.Get(iso.ReverseGet(original)) + assert.Equal(t, original, result) + }) + + t.Run("Handles special characters", func(t *testing.T) { + str := "line1\nline2\ttab\r\nwindows" + bytes := iso.ReverseGet(str) + result := iso.Get(bytes) + assert.Equal(t, str, result) + }) + + t.Run("Handles binary-like data", func(t *testing.T) { + bytes := []byte{0x48, 0x65, 0x6c, 0x6c, 0x6f} // "Hello" + result := iso.Get(bytes) + assert.Equal(t, "Hello", result) + }) +} + +// TestLines tests the Lines isomorphism +func TestLines(t *testing.T) { + iso := Lines() + + t.Run("Get joins lines with newline", func(t *testing.T) { + lines := []string{"line1", "line2", "line3"} + result := iso.Get(lines) + assert.Equal(t, "line1\nline2\nline3", result) + }) + + t.Run("Get handles single line", func(t *testing.T) { + lines := []string{"single line"} + result := iso.Get(lines) + assert.Equal(t, "single line", result) + }) + + t.Run("Get handles empty slice", func(t *testing.T) { + lines := []string{} + result := iso.Get(lines) + assert.Equal(t, "", result) + }) + + t.Run("Get handles empty strings in slice", func(t *testing.T) { + lines := []string{"a", "", "b"} + result := iso.Get(lines) + assert.Equal(t, "a\n\nb", result) + }) + + t.Run("Get handles slice with only empty strings", func(t *testing.T) { + lines := []string{"", "", ""} + result := iso.Get(lines) + assert.Equal(t, "\n\n", result) + }) + + t.Run("ReverseGet splits string by newline", func(t *testing.T) { + str := "line1\nline2\nline3" + result := iso.ReverseGet(str) + assert.Equal(t, []string{"line1", "line2", "line3"}, result) + }) + + t.Run("ReverseGet handles single line", func(t *testing.T) { + str := "single line" + result := iso.ReverseGet(str) + assert.Equal(t, []string{"single line"}, result) + }) + + t.Run("ReverseGet handles empty string", func(t *testing.T) { + str := "" + result := iso.ReverseGet(str) + assert.Equal(t, []string{""}, result) + }) + + t.Run("ReverseGet handles consecutive newlines", func(t *testing.T) { + str := "a\n\nb" + result := iso.ReverseGet(str) + assert.Equal(t, []string{"a", "", "b"}, result) + }) + + t.Run("ReverseGet handles trailing newline", func(t *testing.T) { + str := "a\nb\n" + result := iso.ReverseGet(str) + assert.Equal(t, []string{"a", "b", ""}, result) + }) + + t.Run("ReverseGet handles leading newline", func(t *testing.T) { + str := "\na\nb" + result := iso.ReverseGet(str) + assert.Equal(t, []string{"", "a", "b"}, result) + }) + + t.Run("Round-trip lines to string to lines", func(t *testing.T) { + original := []string{"line1", "line2", "line3"} + result := iso.ReverseGet(iso.Get(original)) + assert.Equal(t, original, result) + }) + + t.Run("Round-trip string to lines to string", func(t *testing.T) { + original := "line1\nline2\nline3" + result := iso.Get(iso.ReverseGet(original)) + assert.Equal(t, original, result) + }) + + t.Run("Handles lines with special characters", func(t *testing.T) { + lines := []string{"Hello δΈ–η•Œ", "🌍 Earth", "tab\there"} + text := iso.Get(lines) + result := iso.ReverseGet(text) + assert.Equal(t, lines, result) + }) + + t.Run("Preserves whitespace in lines", func(t *testing.T) { + lines := []string{" indented", "normal", "\ttabbed"} + text := iso.Get(lines) + result := iso.ReverseGet(text) + assert.Equal(t, lines, result) + }) +} + +// TestUnixMilli tests the UnixMilli isomorphism +func TestUnixMilli(t *testing.T) { + iso := UnixMilli() + + t.Run("Get converts milliseconds to time", func(t *testing.T) { + millis := int64(1609459200000) // 2021-01-01 00:00:00 UTC + result := iso.Get(millis) + // Compare Unix timestamps to avoid timezone issues + assert.Equal(t, millis, result.UnixMilli()) + }) + + t.Run("Get handles zero milliseconds (Unix epoch)", func(t *testing.T) { + millis := int64(0) + result := iso.Get(millis) + assert.Equal(t, millis, result.UnixMilli()) + }) + + t.Run("Get handles negative milliseconds (before epoch)", func(t *testing.T) { + millis := int64(-86400000) // 1 day before epoch + result := iso.Get(millis) + assert.Equal(t, millis, result.UnixMilli()) + }) + + t.Run("ReverseGet converts time to milliseconds", func(t *testing.T) { + tm := time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC) + result := iso.ReverseGet(tm) + assert.Equal(t, int64(1609459200000), result) + }) + + t.Run("ReverseGet handles Unix epoch", func(t *testing.T) { + tm := time.Unix(0, 0).UTC() + result := iso.ReverseGet(tm) + assert.Equal(t, int64(0), result) + }) + + t.Run("ReverseGet handles time before epoch", func(t *testing.T) { + tm := time.Date(1969, 12, 31, 0, 0, 0, 0, time.UTC) + result := iso.ReverseGet(tm) + assert.Equal(t, int64(-86400000), result) + }) + + t.Run("Round-trip milliseconds to time to milliseconds", func(t *testing.T) { + original := int64(1234567890000) + result := iso.ReverseGet(iso.Get(original)) + assert.Equal(t, original, result) + }) + + t.Run("Round-trip time to milliseconds to time", func(t *testing.T) { + original := time.Date(2021, 6, 15, 12, 30, 45, 0, time.UTC) + result := iso.Get(iso.ReverseGet(original)) + // Compare as Unix timestamps to avoid timezone issues + assert.Equal(t, original.UnixMilli(), result.UnixMilli()) + }) + + t.Run("Truncates sub-millisecond precision", func(t *testing.T) { + // Time with nanoseconds + tm := time.Date(2021, 1, 1, 0, 0, 0, 123456789, time.UTC) + millis := iso.ReverseGet(tm) + result := iso.Get(millis) + + // Should have millisecond precision only - compare timestamps + assert.Equal(t, tm.Truncate(time.Millisecond).UnixMilli(), result.UnixMilli()) + }) + + t.Run("Handles current time", func(t *testing.T) { + now := time.Now() + millis := iso.ReverseGet(now) + result := iso.Get(millis) + + // Should be equal within millisecond precision + assert.Equal(t, now.Truncate(time.Millisecond), result.Truncate(time.Millisecond)) + }) + + t.Run("Handles far future date", func(t *testing.T) { + future := time.Date(2100, 12, 31, 23, 59, 59, 0, time.UTC) + millis := iso.ReverseGet(future) + result := iso.Get(millis) + assert.Equal(t, future.UnixMilli(), result.UnixMilli()) + }) + + t.Run("Handles far past date", func(t *testing.T) { + past := time.Date(1900, 1, 1, 0, 0, 0, 0, time.UTC) + millis := iso.ReverseGet(past) + result := iso.Get(millis) + assert.Equal(t, past.UnixMilli(), result.UnixMilli()) + }) + + t.Run("Preserves timezone information in round-trip", func(t *testing.T) { + // Create time in different timezone + loc, _ := time.LoadLocation("America/New_York") + tm := time.Date(2021, 6, 15, 12, 0, 0, 0, loc) + + // Convert to millis and back + millis := iso.ReverseGet(tm) + result := iso.Get(millis) + + // Times should represent the same instant (even if timezone differs) + assert.True(t, tm.Equal(result)) + }) +} + +// TestUTF8StringRoundTripLaws verifies isomorphism laws for UTF8String +func TestUTF8StringRoundTripLaws(t *testing.T) { + iso := UTF8String() + + t.Run("Law 1: ReverseGet(Get(bytes)) == bytes", func(t *testing.T) { + testCases := [][]byte{ + []byte("hello"), + []byte(""), + []byte("Hello δΈ–η•Œ 🌍"), + []byte{0x48, 0x65, 0x6c, 0x6c, 0x6f}, + } + + for _, original := range testCases { + result := iso.ReverseGet(iso.Get(original)) + assert.Equal(t, original, result) + } + }) + + t.Run("Law 2: Get(ReverseGet(str)) == str", func(t *testing.T) { + testCases := []string{ + "hello", + "", + "Hello δΈ–η•Œ 🌍", + "special\nchars\ttab", + } + + for _, original := range testCases { + result := iso.Get(iso.ReverseGet(original)) + assert.Equal(t, original, result) + } + }) +} + +// TestLinesRoundTripLaws verifies isomorphism laws for Lines +func TestLinesRoundTripLaws(t *testing.T) { + iso := Lines() + + t.Run("Law 1: ReverseGet(Get(lines)) == lines", func(t *testing.T) { + testCases := [][]string{ + {"line1", "line2"}, + {"single"}, + {"a", "", "b"}, + {"", "", ""}, + } + + for _, original := range testCases { + result := iso.ReverseGet(iso.Get(original)) + assert.Equal(t, original, result) + } + }) + + t.Run("Law 1: Empty slice special case", func(t *testing.T) { + // Empty slice becomes "" which splits to [""] + // This is expected behavior of strings.Split + original := []string{} + text := iso.Get(original) // "" + result := iso.ReverseGet(text) // [""] + assert.Equal(t, []string{""}, result) + }) + + t.Run("Law 2: Get(ReverseGet(str)) == str", func(t *testing.T) { + testCases := []string{ + "line1\nline2", + "single", + "", + "a\n\nb", + "\n\n", + } + + for _, original := range testCases { + result := iso.Get(iso.ReverseGet(original)) + assert.Equal(t, original, result) + } + }) +} + +// TestUnixMilliRoundTripLaws verifies isomorphism laws for UnixMilli +func TestUnixMilliRoundTripLaws(t *testing.T) { + iso := UnixMilli() + + t.Run("Law 1: ReverseGet(Get(millis)) == millis", func(t *testing.T) { + testCases := []int64{ + 0, + 1609459200000, + -86400000, + 1234567890000, + time.Now().UnixMilli(), + } + + for _, original := range testCases { + result := iso.ReverseGet(iso.Get(original)) + assert.Equal(t, original, result) + } + }) + + t.Run("Law 2: Get(ReverseGet(time)) == time (with millisecond precision)", func(t *testing.T) { + testCases := []time.Time{ + time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), + time.Unix(0, 0).UTC(), + time.Date(1969, 12, 31, 0, 0, 0, 0, time.UTC), + time.Now().Truncate(time.Millisecond), + } + + for _, original := range testCases { + result := iso.Get(iso.ReverseGet(original)) + // Compare Unix timestamps to avoid timezone issues + assert.Equal(t, original.UnixMilli(), result.UnixMilli()) + } + }) +} + +// TestIsosComposition tests composing the isos functions +func TestIsosComposition(t *testing.T) { + t.Run("Compose UTF8String with Lines", func(t *testing.T) { + utf8Iso := UTF8String() + linesIso := Lines() + + // First convert bytes to string, then string to lines + bytes := []byte("line1\nline2\nline3") + str := utf8Iso.Get(bytes) + lines := linesIso.ReverseGet(str) + assert.Equal(t, []string{"line1", "line2", "line3"}, lines) + + // Reverse: lines to string to bytes + originalLines := []string{"a", "b", "c"} + text := linesIso.Get(originalLines) + resultBytes := utf8Iso.ReverseGet(text) + assert.Equal(t, []byte("a\nb\nc"), resultBytes) + }) + + t.Run("Chain UTF8String and Lines operations", func(t *testing.T) { + utf8Iso := UTF8String() + linesIso := Lines() + + // Process: bytes -> string -> lines -> string -> bytes + original := []byte("hello\nworld") + str := utf8Iso.Get(original) + lines := linesIso.ReverseGet(str) + text := linesIso.Get(lines) + result := utf8Iso.ReverseGet(text) + + assert.Equal(t, original, result) + }) +} diff --git a/v2/optics/iso/option/isos.go b/v2/optics/iso/option/isos.go new file mode 100644 index 0000000..9e43b6d --- /dev/null +++ b/v2/optics/iso/option/isos.go @@ -0,0 +1,83 @@ +// Copyright (c) 2023 - 2025 IBM Corp. +// All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package option provides isomorphisms for working with Option types. +// It offers utilities to convert between regular values and Option-wrapped values, +// particularly useful for handling zero values and optional data. +package option + +import ( + "github.com/IBM/fp-go/v2/optics/iso" + "github.com/IBM/fp-go/v2/option" +) + +// FromZero creates an isomorphism between a comparable type T and Option[T]. +// The isomorphism treats the zero value of T as None and non-zero values as Some. +// +// This is particularly useful for types where the zero value has special meaning +// (e.g., 0 for numbers, "" for strings, nil for pointers) and you want to represent +// the absence of a meaningful value using Option. +// +// Type Parameters: +// - T: A comparable type (must support == and != operators) +// +// Returns: +// - An Iso[T, Option[T]] where: +// - Get: Converts T to Option[T] (zero value β†’ None, non-zero β†’ Some) +// - ReverseGet: Converts Option[T] to T (None β†’ zero value, Some β†’ unwrapped value) +// +// Behavior: +// - Get direction: If the value equals the zero value of T, returns None; otherwise returns Some(value) +// - ReverseGet direction: If the Option is None, returns the zero value; otherwise returns the unwrapped value +// +// Example with integers: +// +// isoInt := FromZero[int]() +// opt := isoInt.Get(0) // None (0 is the zero value) +// opt = isoInt.Get(42) // Some(42) +// val := isoInt.ReverseGet(option.None[int]()) // 0 +// val = isoInt.ReverseGet(option.Some(42)) // 42 +// +// Example with strings: +// +// isoStr := FromZero[string]() +// opt := isoStr.Get("") // None ("" is the zero value) +// opt = isoStr.Get("hello") // Some("hello") +// val := isoStr.ReverseGet(option.None[string]()) // "" +// val = isoStr.ReverseGet(option.Some("world")) // "world" +// +// Example with pointers: +// +// isoPtr := FromZero[*int]() +// opt := isoPtr.Get(nil) // None (nil is the zero value) +// num := 42 +// opt = isoPtr.Get(&num) // Some(&num) +// +// Use cases: +// - Converting between database nullable columns and Go types +// - Handling optional configuration values with defaults +// - Working with APIs that use zero values to indicate absence +// - Simplifying validation logic for required vs optional fields +// +// Note: This isomorphism satisfies the round-trip laws: +// - ReverseGet(Get(t)) == t for all t: T +// - Get(ReverseGet(opt)) == opt for all opt: Option[T] +func FromZero[T comparable]() iso.Iso[T, option.Option[T]] { + var zero T + return iso.MakeIso( + option.FromPredicate(func(t T) bool { return t != zero }), + option.GetOrElse(func() T { return zero }), + ) +} diff --git a/v2/optics/iso/option/isos_test.go b/v2/optics/iso/option/isos_test.go new file mode 100644 index 0000000..1b60b97 --- /dev/null +++ b/v2/optics/iso/option/isos_test.go @@ -0,0 +1,366 @@ +// Copyright (c) 2023 - 2025 IBM Corp. +// All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package option + +import ( + "testing" + + F "github.com/IBM/fp-go/v2/function" + "github.com/IBM/fp-go/v2/optics/iso" + O "github.com/IBM/fp-go/v2/option" + "github.com/stretchr/testify/assert" +) + +// TestFromZeroInt tests the FromZero isomorphism with integer type +func TestFromZeroInt(t *testing.T) { + isoInt := FromZero[int]() + + t.Run("Get converts zero to None", func(t *testing.T) { + result := isoInt.Get(0) + assert.True(t, O.IsNone(result)) + }) + + t.Run("Get converts non-zero to Some", func(t *testing.T) { + result := isoInt.Get(42) + assert.True(t, O.IsSome(result)) + assert.Equal(t, 42, O.MonadGetOrElse(result, func() int { return 0 })) + }) + + t.Run("Get converts negative to Some", func(t *testing.T) { + result := isoInt.Get(-5) + assert.True(t, O.IsSome(result)) + assert.Equal(t, -5, O.MonadGetOrElse(result, func() int { return 0 })) + }) + + t.Run("ReverseGet converts None to zero", func(t *testing.T) { + result := isoInt.ReverseGet(O.None[int]()) + assert.Equal(t, 0, result) + }) + + t.Run("ReverseGet converts Some to value", func(t *testing.T) { + result := isoInt.ReverseGet(O.Some(42)) + assert.Equal(t, 42, result) + }) +} + +// TestFromZeroString tests the FromZero isomorphism with string type +func TestFromZeroString(t *testing.T) { + isoStr := FromZero[string]() + + t.Run("Get converts empty string to None", func(t *testing.T) { + result := isoStr.Get("") + assert.True(t, O.IsNone(result)) + }) + + t.Run("Get converts non-empty string to Some", func(t *testing.T) { + result := isoStr.Get("hello") + assert.True(t, O.IsSome(result)) + assert.Equal(t, "hello", O.MonadGetOrElse(result, func() string { return "" })) + }) + + t.Run("ReverseGet converts None to empty string", func(t *testing.T) { + result := isoStr.ReverseGet(O.None[string]()) + assert.Equal(t, "", result) + }) + + t.Run("ReverseGet converts Some to value", func(t *testing.T) { + result := isoStr.ReverseGet(O.Some("world")) + assert.Equal(t, "world", result) + }) +} + +// TestFromZeroFloat tests the FromZero isomorphism with float64 type +func TestFromZeroFloat(t *testing.T) { + isoFloat := FromZero[float64]() + + t.Run("Get converts 0.0 to None", func(t *testing.T) { + result := isoFloat.Get(0.0) + assert.True(t, O.IsNone(result)) + }) + + t.Run("Get converts non-zero float to Some", func(t *testing.T) { + result := isoFloat.Get(3.14) + assert.True(t, O.IsSome(result)) + assert.InDelta(t, 3.14, O.MonadGetOrElse(result, func() float64 { return 0.0 }), 0.001) + }) + + t.Run("ReverseGet converts None to 0.0", func(t *testing.T) { + result := isoFloat.ReverseGet(O.None[float64]()) + assert.Equal(t, 0.0, result) + }) + + t.Run("ReverseGet converts Some to value", func(t *testing.T) { + result := isoFloat.ReverseGet(O.Some(2.718)) + assert.InDelta(t, 2.718, result, 0.001) + }) +} + +// TestFromZeroPointer tests the FromZero isomorphism with pointer type +func TestFromZeroPointer(t *testing.T) { + isoPtr := FromZero[*int]() + + t.Run("Get converts nil to None", func(t *testing.T) { + result := isoPtr.Get(nil) + assert.True(t, O.IsNone(result)) + }) + + t.Run("Get converts non-nil pointer to Some", func(t *testing.T) { + num := 42 + result := isoPtr.Get(&num) + assert.True(t, O.IsSome(result)) + ptr := O.MonadGetOrElse(result, func() *int { return nil }) + assert.NotNil(t, ptr) + assert.Equal(t, 42, *ptr) + }) + + t.Run("ReverseGet converts None to nil", func(t *testing.T) { + result := isoPtr.ReverseGet(O.None[*int]()) + assert.Nil(t, result) + }) + + t.Run("ReverseGet converts Some to pointer", func(t *testing.T) { + num := 99 + result := isoPtr.ReverseGet(O.Some(&num)) + assert.NotNil(t, result) + assert.Equal(t, 99, *result) + }) +} + +// TestFromZeroBool tests the FromZero isomorphism with bool type +func TestFromZeroBool(t *testing.T) { + isoBool := FromZero[bool]() + + t.Run("Get converts false to None", func(t *testing.T) { + result := isoBool.Get(false) + assert.True(t, O.IsNone(result)) + }) + + t.Run("Get converts true to Some", func(t *testing.T) { + result := isoBool.Get(true) + assert.True(t, O.IsSome(result)) + assert.True(t, O.MonadGetOrElse(result, func() bool { return false })) + }) + + t.Run("ReverseGet converts None to false", func(t *testing.T) { + result := isoBool.ReverseGet(O.None[bool]()) + assert.False(t, result) + }) + + t.Run("ReverseGet converts Some to true", func(t *testing.T) { + result := isoBool.ReverseGet(O.Some(true)) + assert.True(t, result) + }) +} + +// TestFromZeroRoundTripLaws verifies the isomorphism laws +func TestFromZeroRoundTripLaws(t *testing.T) { + t.Run("Law 1: ReverseGet(Get(t)) == t for integers", func(t *testing.T) { + isoInt := FromZero[int]() + + // Test with zero value + assert.Equal(t, 0, isoInt.ReverseGet(isoInt.Get(0))) + + // Test with non-zero values + assert.Equal(t, 42, isoInt.ReverseGet(isoInt.Get(42))) + assert.Equal(t, -10, isoInt.ReverseGet(isoInt.Get(-10))) + }) + + t.Run("Law 1: ReverseGet(Get(t)) == t for strings", func(t *testing.T) { + isoStr := FromZero[string]() + + // Test with zero value + assert.Equal(t, "", isoStr.ReverseGet(isoStr.Get(""))) + + // Test with non-zero values + assert.Equal(t, "hello", isoStr.ReverseGet(isoStr.Get("hello"))) + }) + + t.Run("Law 2: Get(ReverseGet(opt)) == opt for None", func(t *testing.T) { + isoInt := FromZero[int]() + + none := O.None[int]() + result := isoInt.Get(isoInt.ReverseGet(none)) + assert.Equal(t, none, result) + }) + + t.Run("Law 2: Get(ReverseGet(opt)) == opt for Some", func(t *testing.T) { + isoInt := FromZero[int]() + + some := O.Some(42) + result := isoInt.Get(isoInt.ReverseGet(some)) + assert.Equal(t, some, result) + }) +} + +// TestFromZeroWithModify tests using FromZero with iso.Modify +func TestFromZeroWithModify(t *testing.T) { + isoInt := FromZero[int]() + + t.Run("Modify applies transformation to non-zero value", func(t *testing.T) { + double := func(opt O.Option[int]) O.Option[int] { + return O.MonadMap(opt, func(x int) int { return x * 2 }) + } + + result := iso.Modify[int](double)(isoInt)(5) + assert.Equal(t, 10, result) + }) + + t.Run("Modify preserves zero value", func(t *testing.T) { + double := func(opt O.Option[int]) O.Option[int] { + return O.MonadMap(opt, func(x int) int { return x * 2 }) + } + + result := iso.Modify[int](double)(isoInt)(0) + assert.Equal(t, 0, result) + }) +} + +// TestFromZeroWithCompose tests composing FromZero with other isomorphisms +func TestFromZeroWithCompose(t *testing.T) { + isoInt := FromZero[int]() + + // Create an isomorphism that doubles/halves values + doubleIso := iso.MakeIso( + func(opt O.Option[int]) O.Option[int] { + return O.MonadMap(opt, func(x int) int { return x * 2 }) + }, + func(opt O.Option[int]) O.Option[int] { + return O.MonadMap(opt, func(x int) int { return x / 2 }) + }, + ) + + composed := F.Pipe1(isoInt, iso.Compose[int](doubleIso)) + + t.Run("Composed isomorphism works with non-zero", func(t *testing.T) { + result := composed.Get(5) + assert.True(t, O.IsSome(result)) + assert.Equal(t, 10, O.MonadGetOrElse(result, func() int { return 0 })) + }) + + t.Run("Composed isomorphism works with zero", func(t *testing.T) { + result := composed.Get(0) + assert.True(t, O.IsNone(result)) + }) + + t.Run("Composed isomorphism reverse works", func(t *testing.T) { + result := composed.ReverseGet(O.Some(20)) + assert.Equal(t, 10, result) + }) +} + +// TestFromZeroWithUnwrapWrap tests using Unwrap and Wrap helpers +func TestFromZeroWithUnwrapWrap(t *testing.T) { + isoInt := FromZero[int]() + + t.Run("Unwrap extracts Option from value", func(t *testing.T) { + result := iso.Unwrap[O.Option[int]](42)(isoInt) + assert.True(t, O.IsSome(result)) + assert.Equal(t, 42, O.MonadGetOrElse(result, func() int { return 0 })) + }) + + t.Run("Wrap creates value from Option", func(t *testing.T) { + result := iso.Wrap[int](O.Some(99))(isoInt) + assert.Equal(t, 99, result) + }) + + t.Run("To is alias for Unwrap", func(t *testing.T) { + result := iso.To[O.Option[int]](42)(isoInt) + assert.True(t, O.IsSome(result)) + }) + + t.Run("From is alias for Wrap", func(t *testing.T) { + result := iso.From[int](O.Some(99))(isoInt) + assert.Equal(t, 99, result) + }) +} + +// TestFromZeroWithReverse tests reversing the isomorphism +func TestFromZeroWithReverse(t *testing.T) { + isoInt := FromZero[int]() + reversed := iso.Reverse(isoInt) + + t.Run("Reversed Get is original ReverseGet", func(t *testing.T) { + result := reversed.Get(O.Some(42)) + assert.Equal(t, 42, result) + }) + + t.Run("Reversed ReverseGet is original Get", func(t *testing.T) { + result := reversed.ReverseGet(42) + assert.True(t, O.IsSome(result)) + assert.Equal(t, 42, O.MonadGetOrElse(result, func() int { return 0 })) + }) +} + +// TestFromZeroCustomType tests FromZero with a custom comparable type +func TestFromZeroCustomType(t *testing.T) { + type UserID int + + isoUserID := FromZero[UserID]() + + t.Run("Get converts zero UserID to None", func(t *testing.T) { + result := isoUserID.Get(UserID(0)) + assert.True(t, O.IsNone(result)) + }) + + t.Run("Get converts non-zero UserID to Some", func(t *testing.T) { + result := isoUserID.Get(UserID(123)) + assert.True(t, O.IsSome(result)) + assert.Equal(t, UserID(123), O.MonadGetOrElse(result, func() UserID { return 0 })) + }) + + t.Run("ReverseGet converts None to zero UserID", func(t *testing.T) { + result := isoUserID.ReverseGet(O.None[UserID]()) + assert.Equal(t, UserID(0), result) + }) + + t.Run("ReverseGet converts Some to UserID", func(t *testing.T) { + result := isoUserID.ReverseGet(O.Some(UserID(456))) + assert.Equal(t, UserID(456), result) + }) +} + +// TestFromZeroEdgeCases tests edge cases and boundary conditions +func TestFromZeroEdgeCases(t *testing.T) { + t.Run("Works with maximum int value", func(t *testing.T) { + isoInt := FromZero[int]() + maxInt := int(^uint(0) >> 1) + + result := isoInt.Get(maxInt) + assert.True(t, O.IsSome(result)) + assert.Equal(t, maxInt, isoInt.ReverseGet(result)) + }) + + t.Run("Works with minimum int value", func(t *testing.T) { + isoInt := FromZero[int]() + minInt := -int(^uint(0)>>1) - 1 + + result := isoInt.Get(minInt) + assert.True(t, O.IsSome(result)) + assert.Equal(t, minInt, isoInt.ReverseGet(result)) + }) + + t.Run("Works with very long strings", func(t *testing.T) { + isoStr := FromZero[string]() + longStr := string(make([]byte, 10000)) + for i := range longStr { + longStr = longStr[:i] + "a" + longStr[i+1:] + } + + result := isoStr.Get(longStr) + assert.True(t, O.IsSome(result)) + assert.Equal(t, longStr, isoStr.ReverseGet(result)) + }) +} diff --git a/v2/optics/prism/prisms.go b/v2/optics/prism/prisms.go index 066904a..1ffcd83 100644 --- a/v2/optics/prism/prisms.go +++ b/v2/optics/prism/prisms.go @@ -18,6 +18,7 @@ package prism import ( "encoding/base64" "net/url" + "regexp" "time" "github.com/IBM/fp-go/v2/either" @@ -310,3 +311,349 @@ func Deref[T any]() Prism[*T, *T] { func FromEither[E, T any]() Prism[Either[E, T], T] { return MakePrism(either.ToOption[E, T], either.Of[E, T]) } + +// FromZero creates a prism that matches zero values of comparable types. +// It provides a safe way to work with zero values, handling non-zero values +// gracefully through the Option type. +// +// The prism's GetOption returns Some(t) if the value equals the zero value +// of type T; otherwise, it returns None. +// +// The prism's ReverseGet is the identity function, returning the value unchanged. +// +// Type Parameters: +// - T: A comparable type (must support == and != operators) +// +// Returns: +// - A Prism[T, T] that matches zero values +// +// Example: +// +// // Create a prism for zero integers +// zeroPrism := FromZero[int]() +// +// // Match zero value +// result := zeroPrism.GetOption(0) // Some(0) +// +// // Non-zero returns None +// result = zeroPrism.GetOption(42) // None[int]() +// +// // ReverseGet is identity +// value := zeroPrism.ReverseGet(0) // 0 +// +// // Use with Set to update zero values +// setter := Set[int, int](100) +// result := setter(zeroPrism)(0) // 100 +// result = setter(zeroPrism)(42) // 42 (unchanged) +// +// Common use cases: +// - Validating that values are zero/default +// - Filtering zero values in data pipelines +// - Working with optional fields that use zero as "not set" +// - Replacing zero values with defaults +func FromZero[T comparable]() Prism[T, T] { + var zero T + return MakePrism(option.FromPredicate(func(t T) bool { return t == zero }), F.Identity[T]) +} + +// Match represents a regex match result with full reconstruction capability. +// It contains everything needed to reconstruct the original string, making it +// suitable for use in a prism that maintains bidirectionality. +// +// Fields: +// - Before: Text before the match +// - Groups: Capture groups (index 0 is the full match, 1+ are capture groups) +// - After: Text after the match +// +// Example: +// +// // For string "hello world 123" with regex `\d+`: +// // Match{ +// // Before: "hello world ", +// // Groups: []string{"123"}, +// // After: "", +// // } +type Match struct { + Before string // Text before the match + Groups []string // Capture groups (index 0 is full match) + After string // Text after the match +} + +// Reconstruct builds the original string from a Match. +// This is the inverse operation of regex matching, allowing full round-trip conversion. +// +// Returns: +// - The original string that was matched +// +// Example: +// +// match := Match{ +// Before: "hello ", +// Groups: []string{"world"}, +// After: "!", +// } +// original := match.Reconstruct() // "hello world!" +func (m Match) Reconstruct() string { + return m.Before + m.Groups[0] + m.After +} + +// FullMatch returns the complete matched text (the entire regex match). +// This is equivalent to Groups[0] and represents what the regex matched. +// +// Returns: +// - The full matched text +// +// Example: +// +// match := Match{ +// Before: "price: ", +// Groups: []string{"$99.99", "99.99"}, +// After: " USD", +// } +// full := match.FullMatch() // "$99.99" +func (m Match) FullMatch() string { + return m.Groups[0] +} + +// Group returns the nth capture group from the match (1-indexed). +// Capture group 0 is the full match, groups 1+ are the parenthesized captures. +// Returns an empty string if the group index is out of bounds. +// +// Parameters: +// - n: The capture group index (1-indexed) +// +// Returns: +// - The captured text, or empty string if index is invalid +// +// Example: +// +// // Regex: `(\w+)@(\w+\.\w+)` matching "user@example.com" +// match := Match{ +// Groups: []string{"user@example.com", "user", "example.com"}, +// } +// username := match.Group(1) // "user" +// domain := match.Group(2) // "example.com" +// invalid := match.Group(5) // "" +func (m Match) Group(n int) string { + if n < len(m.Groups) { + return m.Groups[n] + } + return "" +} + +// RegexMatcher creates a prism for regex pattern matching with full reconstruction. +// It provides a safe way to match strings against a regex pattern, extracting +// match information while maintaining the ability to reconstruct the original string. +// +// The prism's GetOption attempts to match the regex against the string. +// If a match is found, it returns Some(Match) with all capture groups and context; +// if no match is found, it returns None. +// +// The prism's ReverseGet reconstructs the original string from a Match. +// +// Parameters: +// - re: A compiled regular expression +// +// Returns: +// - A Prism[string, Match] that safely handles regex matching +// +// Example: +// +// // Create a prism for matching numbers +// numRegex := regexp.MustCompile(`\d+`) +// numPrism := RegexMatcher(numRegex) +// +// // Match a string +// match := numPrism.GetOption("price: 42 dollars") +// // Some(Match{Before: "price: ", Groups: ["42"], After: " dollars"}) +// +// // No match returns None +// noMatch := numPrism.GetOption("no numbers here") // None[Match]() +// +// // Reconstruct original string +// if m, ok := option.IsSome(match); ok { +// original := numPrism.ReverseGet(m) // "price: 42 dollars" +// } +// +// // Extract capture groups +// emailRegex := regexp.MustCompile(`(\w+)@(\w+\.\w+)`) +// emailPrism := RegexMatcher(emailRegex) +// match = emailPrism.GetOption("contact: user@example.com") +// // Match.Group(1) = "user", Match.Group(2) = "example.com" +// +// Common use cases: +// - Parsing structured text with regex patterns +// - Extracting and validating data from strings +// - Text transformation pipelines +// - Pattern-based string manipulation with reconstruction +// +// Note: This prism is bijective - you can always reconstruct the original +// string from a Match, making it suitable for round-trip transformations. +func RegexMatcher(re *regexp.Regexp) Prism[string, Match] { + noMatch := option.None[Match]() + + return MakePrism( + // String -> Option[Match] + func(s string) Option[Match] { + loc := re.FindStringSubmatchIndex(s) + if loc == nil { + return noMatch + } + + // Extract all capture groups + groups := make([]string, 0) + for i := 0; i < len(loc); i += 2 { + if loc[i] >= 0 { + groups = append(groups, s[loc[i]:loc[i+1]]) + } else { + groups = append(groups, "") + } + } + + match := Match{ + Before: s[:loc[0]], + Groups: groups, + After: s[loc[1]:], + } + + return option.Some(match) + }, + Match.Reconstruct, + ) +} + +// NamedMatch represents a regex match result with named capture groups. +// It provides access to captured text by name rather than by index, making +// regex patterns more readable and maintainable. +// +// Fields: +// - Before: Text before the match +// - Groups: Map of capture group names to their matched text +// - Full: The complete matched text +// - After: Text after the match +// +// Example: +// +// // For regex `(?P\w+)@(?P\w+\.\w+)` matching "user@example.com": +// // NamedMatch{ +// // Before: "", +// // Groups: map[string]string{"user": "user", "domain": "example.com"}, +// // Full: "user@example.com", +// // After: "", +// // } +type NamedMatch struct { + Before string + Groups map[string]string + Full string // The full matched text + After string +} + +// Reconstruct builds the original string from a NamedMatch. +// This is the inverse operation of regex matching, allowing full round-trip conversion. +// +// Returns: +// - The original string that was matched +// +// Example: +// +// match := NamedMatch{ +// Before: "email: ", +// Full: "user@example.com", +// Groups: map[string]string{"user": "user", "domain": "example.com"}, +// After: "", +// } +// original := match.Reconstruct() // "email: user@example.com" +func (nm NamedMatch) Reconstruct() string { + return nm.Before + nm.Full + nm.After +} + +// RegexNamedMatcher creates a prism for regex pattern matching with named capture groups. +// It provides a safe way to match strings against a regex pattern with named groups, +// making it easier to extract specific parts of the match by name rather than index. +// +// The prism's GetOption attempts to match the regex against the string. +// If a match is found, it returns Some(NamedMatch) with all named capture groups; +// if no match is found, it returns None. +// +// The prism's ReverseGet reconstructs the original string from a NamedMatch. +// +// Parameters: +// - re: A compiled regular expression with named capture groups +// +// Returns: +// - A Prism[string, NamedMatch] that safely handles regex matching with named groups +// +// Example: +// +// // Create a prism for matching email addresses with named groups +// emailRegex := regexp.MustCompile(`(?P\w+)@(?P\w+\.\w+)`) +// emailPrism := RegexNamedMatcher(emailRegex) +// +// // Match a string +// match := emailPrism.GetOption("contact: user@example.com") +// // Some(NamedMatch{ +// // Before: "contact: ", +// // Groups: {"user": "user", "domain": "example.com"}, +// // Full: "user@example.com", +// // After: "", +// // }) +// +// // Access named groups +// if m, ok := option.IsSome(match); ok { +// username := m.Groups["user"] // "user" +// domain := m.Groups["domain"] // "example.com" +// } +// +// // No match returns None +// noMatch := emailPrism.GetOption("invalid-email") // None[NamedMatch]() +// +// // Reconstruct original string +// if m, ok := option.IsSome(match); ok { +// original := emailPrism.ReverseGet(m) // "contact: user@example.com" +// } +// +// // More complex example with date parsing +// dateRegex := regexp.MustCompile(`(?P\d{4})-(?P\d{2})-(?P\d{2})`) +// datePrism := RegexNamedMatcher(dateRegex) +// match = datePrism.GetOption("Date: 2024-03-15") +// // Access: match.Groups["year"], match.Groups["month"], match.Groups["day"] +// +// Common use cases: +// - Parsing structured text with meaningful field names +// - Extracting and validating data from formatted strings +// - Log parsing with named fields +// - Configuration file parsing +// - URL route parameter extraction +// +// Note: Only named capture groups appear in the Groups map. Unnamed groups +// are not included. The Full field always contains the complete matched text. +func RegexNamedMatcher(re *regexp.Regexp) Prism[string, NamedMatch] { + names := re.SubexpNames() + noMatch := option.None[NamedMatch]() + + return MakePrism( + func(s string) Option[NamedMatch] { + loc := re.FindStringSubmatchIndex(s) + if loc == nil { + return noMatch + } + + groups := make(map[string]string) + for i := 1; i < len(loc)/2; i++ { + if names[i] != "" && loc[2*i] >= 0 { + groups[names[i]] = s[loc[2*i]:loc[2*i+1]] + } + } + + match := NamedMatch{ + Before: s[:loc[0]], + Groups: groups, + Full: s[loc[0]:loc[1]], + After: s[loc[1]:], + } + + return option.Some(match) + }, + NamedMatch.Reconstruct, + ) +} diff --git a/v2/optics/prism/prisms_test.go b/v2/optics/prism/prisms_test.go new file mode 100644 index 0000000..c46a5b3 --- /dev/null +++ b/v2/optics/prism/prisms_test.go @@ -0,0 +1,534 @@ +// Copyright (c) 2023 - 2025 IBM Corp. +// All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package prism + +import ( + "regexp" + "testing" + + F "github.com/IBM/fp-go/v2/function" + O "github.com/IBM/fp-go/v2/option" + "github.com/stretchr/testify/assert" +) + +// TestFromZero tests the FromZero prism with various comparable types +func TestFromZero(t *testing.T) { + t.Run("int - match zero", func(t *testing.T) { + prism := FromZero[int]() + + result := prism.GetOption(0) + assert.True(t, O.IsSome(result)) + assert.Equal(t, 0, O.GetOrElse(F.Constant(-1))(result)) + }) + + t.Run("int - non-zero returns None", func(t *testing.T) { + prism := FromZero[int]() + + result := prism.GetOption(42) + assert.True(t, O.IsNone(result)) + }) + + t.Run("string - match empty string", func(t *testing.T) { + prism := FromZero[string]() + + result := prism.GetOption("") + assert.True(t, O.IsSome(result)) + assert.Equal(t, "", O.GetOrElse(F.Constant("default"))(result)) + }) + + t.Run("string - non-empty returns None", func(t *testing.T) { + prism := FromZero[string]() + + result := prism.GetOption("hello") + assert.True(t, O.IsNone(result)) + }) + + t.Run("bool - match false", func(t *testing.T) { + prism := FromZero[bool]() + + result := prism.GetOption(false) + assert.True(t, O.IsSome(result)) + assert.False(t, O.GetOrElse(F.Constant(true))(result)) + }) + + t.Run("bool - true returns None", func(t *testing.T) { + prism := FromZero[bool]() + + result := prism.GetOption(true) + assert.True(t, O.IsNone(result)) + }) + + t.Run("float64 - match 0.0", func(t *testing.T) { + prism := FromZero[float64]() + + result := prism.GetOption(0.0) + assert.True(t, O.IsSome(result)) + assert.Equal(t, 0.0, O.GetOrElse(F.Constant(-1.0))(result)) + }) + + t.Run("float64 - non-zero returns None", func(t *testing.T) { + prism := FromZero[float64]() + + result := prism.GetOption(3.14) + assert.True(t, O.IsNone(result)) + }) + + t.Run("pointer - match nil", func(t *testing.T) { + prism := FromZero[*int]() + + var nilPtr *int + result := prism.GetOption(nilPtr) + assert.True(t, O.IsSome(result)) + }) + + t.Run("pointer - non-nil returns None", func(t *testing.T) { + prism := FromZero[*int]() + + value := 42 + result := prism.GetOption(&value) + assert.True(t, O.IsNone(result)) + }) + + t.Run("reverse get is identity", func(t *testing.T) { + prism := FromZero[int]() + + assert.Equal(t, 0, prism.ReverseGet(0)) + assert.Equal(t, 42, prism.ReverseGet(42)) + }) +} + +// TestFromZeroWithSet tests using Set with FromZero prism +func TestFromZeroWithSet(t *testing.T) { + t.Run("set on zero value", func(t *testing.T) { + prism := FromZero[int]() + + setter := Set[int, int](100) + result := setter(prism)(0) + + assert.Equal(t, 100, result) + }) + + t.Run("set on non-zero returns original", func(t *testing.T) { + prism := FromZero[int]() + + setter := Set[int, int](100) + result := setter(prism)(42) + + assert.Equal(t, 42, result) + }) +} + +// TestFromZeroPrismLaws tests that FromZero satisfies prism laws +func TestFromZeroPrismLaws(t *testing.T) { + t.Run("law 1: GetOption(ReverseGet(a)) == Some(a) for zero", func(t *testing.T) { + prism := FromZero[int]() + + reversed := prism.ReverseGet(0) + extracted := prism.GetOption(reversed) + + assert.True(t, O.IsSome(extracted)) + assert.Equal(t, 0, O.GetOrElse(F.Constant(-1))(extracted)) + }) + + t.Run("law 2: if GetOption(s) == Some(a), then ReverseGet(a) == s", func(t *testing.T) { + prism := FromZero[string]() + + extracted := prism.GetOption("") + if O.IsSome(extracted) { + value := O.GetOrElse(F.Constant("default"))(extracted) + reconstructed := prism.ReverseGet(value) + assert.Equal(t, "", reconstructed) + } + }) +} + +// TestRegexMatcher tests the RegexMatcher prism +func TestRegexMatcher(t *testing.T) { + t.Run("simple number match", func(t *testing.T) { + re := regexp.MustCompile(`\d+`) + prism := RegexMatcher(re) + + result := prism.GetOption("price: 42 dollars") + assert.True(t, O.IsSome(result)) + + match := O.GetOrElse(F.Constant(Match{}))(result) + assert.Equal(t, "price: ", match.Before) + assert.Equal(t, "42", match.FullMatch()) + assert.Equal(t, " dollars", match.After) + }) + + t.Run("no match returns None", func(t *testing.T) { + re := regexp.MustCompile(`\d+`) + prism := RegexMatcher(re) + + result := prism.GetOption("no numbers here") + assert.True(t, O.IsNone(result)) + }) + + t.Run("match with capture groups", func(t *testing.T) { + re := regexp.MustCompile(`(\w+)@(\w+\.\w+)`) + prism := RegexMatcher(re) + + result := prism.GetOption("contact: user@example.com") + assert.True(t, O.IsSome(result)) + + match := O.GetOrElse(F.Constant(Match{}))(result) + assert.Equal(t, "contact: ", match.Before) + assert.Equal(t, "user@example.com", match.FullMatch()) + assert.Equal(t, "user", match.Group(1)) + assert.Equal(t, "example.com", match.Group(2)) + assert.Equal(t, "", match.After) + }) + + t.Run("match at beginning", func(t *testing.T) { + re := regexp.MustCompile(`^\d+`) + prism := RegexMatcher(re) + + result := prism.GetOption("123 test") + assert.True(t, O.IsSome(result)) + + match := O.GetOrElse(F.Constant(Match{}))(result) + assert.Equal(t, "", match.Before) + assert.Equal(t, "123", match.FullMatch()) + assert.Equal(t, " test", match.After) + }) + + t.Run("match at end", func(t *testing.T) { + re := regexp.MustCompile(`\d+$`) + prism := RegexMatcher(re) + + result := prism.GetOption("test 123") + assert.True(t, O.IsSome(result)) + + match := O.GetOrElse(F.Constant(Match{}))(result) + assert.Equal(t, "test ", match.Before) + assert.Equal(t, "123", match.FullMatch()) + assert.Equal(t, "", match.After) + }) + + t.Run("reconstruct original string", func(t *testing.T) { + re := regexp.MustCompile(`\d+`) + prism := RegexMatcher(re) + + original := "price: 42 dollars" + result := prism.GetOption(original) + assert.True(t, O.IsSome(result)) + + match := O.GetOrElse(F.Constant(Match{}))(result) + reconstructed := match.Reconstruct() + assert.Equal(t, original, reconstructed) + }) + + t.Run("reverse get reconstructs", func(t *testing.T) { + re := regexp.MustCompile(`\d+`) + prism := RegexMatcher(re) + + match := Match{ + Before: "price: ", + Groups: []string{"42"}, + After: " dollars", + } + + reconstructed := prism.ReverseGet(match) + assert.Equal(t, "price: 42 dollars", reconstructed) + }) + + t.Run("Group with invalid index returns empty", func(t *testing.T) { + match := Match{ + Groups: []string{"full", "group1"}, + } + + assert.Equal(t, "full", match.Group(0)) + assert.Equal(t, "group1", match.Group(1)) + assert.Equal(t, "", match.Group(5)) + }) + + t.Run("empty string match", func(t *testing.T) { + re := regexp.MustCompile(`.*`) + prism := RegexMatcher(re) + + result := prism.GetOption("") + assert.True(t, O.IsSome(result)) + + match := O.GetOrElse(F.Constant(Match{}))(result) + assert.Equal(t, "", match.Before) + assert.Equal(t, "", match.FullMatch()) + assert.Equal(t, "", match.After) + }) +} + +// TestRegexMatcherPrismLaws tests that RegexMatcher satisfies prism laws +func TestRegexMatcherPrismLaws(t *testing.T) { + re := regexp.MustCompile(`\d+`) + prism := RegexMatcher(re) + + t.Run("law 1: GetOption(ReverseGet(match)) reconstructs", func(t *testing.T) { + match := Match{ + Before: "test ", + Groups: []string{"123"}, + After: " end", + } + + str := prism.ReverseGet(match) + result := prism.GetOption(str) + + assert.True(t, O.IsSome(result)) + reconstructed := O.GetOrElse(F.Constant(Match{}))(result) + assert.Equal(t, match.Before, reconstructed.Before) + assert.Equal(t, match.Groups[0], reconstructed.Groups[0]) + assert.Equal(t, match.After, reconstructed.After) + }) + + t.Run("law 2: ReverseGet(GetOption(s)) == s for matching strings", func(t *testing.T) { + original := "value: 42 units" + extracted := prism.GetOption(original) + + if O.IsSome(extracted) { + match := O.GetOrElse(F.Constant(Match{}))(extracted) + reconstructed := prism.ReverseGet(match) + assert.Equal(t, original, reconstructed) + } + }) +} + +// TestRegexNamedMatcher tests the RegexNamedMatcher prism +func TestRegexNamedMatcher(t *testing.T) { + t.Run("email with named groups", func(t *testing.T) { + re := regexp.MustCompile(`(?P\w+)@(?P\w+\.\w+)`) + prism := RegexNamedMatcher(re) + + result := prism.GetOption("contact: user@example.com") + assert.True(t, O.IsSome(result)) + + match := O.GetOrElse(F.Constant(NamedMatch{}))(result) + assert.Equal(t, "contact: ", match.Before) + assert.Equal(t, "user@example.com", match.Full) + assert.Equal(t, "", match.After) + assert.Equal(t, "user", match.Groups["user"]) + assert.Equal(t, "example.com", match.Groups["domain"]) + }) + + t.Run("date with named groups", func(t *testing.T) { + re := regexp.MustCompile(`(?P\d{4})-(?P\d{2})-(?P\d{2})`) + prism := RegexNamedMatcher(re) + + result := prism.GetOption("Date: 2024-03-15") + assert.True(t, O.IsSome(result)) + + match := O.GetOrElse(F.Constant(NamedMatch{}))(result) + assert.Equal(t, "Date: ", match.Before) + assert.Equal(t, "2024-03-15", match.Full) + assert.Equal(t, "2024", match.Groups["year"]) + assert.Equal(t, "03", match.Groups["month"]) + assert.Equal(t, "15", match.Groups["day"]) + }) + + t.Run("no match returns None", func(t *testing.T) { + re := regexp.MustCompile(`(?P\d+)`) + prism := RegexNamedMatcher(re) + + result := prism.GetOption("no numbers") + assert.True(t, O.IsNone(result)) + }) + + t.Run("reconstruct original string", func(t *testing.T) { + re := regexp.MustCompile(`(?P\w+)@(?P\w+\.\w+)`) + prism := RegexNamedMatcher(re) + + original := "email: admin@site.com here" + result := prism.GetOption(original) + assert.True(t, O.IsSome(result)) + + match := O.GetOrElse(F.Constant(NamedMatch{}))(result) + reconstructed := match.Reconstruct() + assert.Equal(t, original, reconstructed) + }) + + t.Run("reverse get reconstructs", func(t *testing.T) { + re := regexp.MustCompile(`(?P\d+)`) + prism := RegexNamedMatcher(re) + + match := NamedMatch{ + Before: "value: ", + Full: "42", + Groups: map[string]string{"num": "42"}, + After: " end", + } + + reconstructed := prism.ReverseGet(match) + assert.Equal(t, "value: 42 end", reconstructed) + }) + + t.Run("unnamed groups not in map", func(t *testing.T) { + // Mix of named and unnamed groups - use non-greedy match for clarity + re := regexp.MustCompile(`(?P[a-z]+)(\d+)`) + prism := RegexNamedMatcher(re) + + result := prism.GetOption("user123") + assert.True(t, O.IsSome(result)) + + match := O.GetOrElse(F.Constant(NamedMatch{}))(result) + assert.Equal(t, "user123", match.Full) + assert.Equal(t, "user", match.Groups["name"]) + // Only named groups should be in the map, not unnamed ones + assert.Equal(t, 1, len(match.Groups)) + }) + + t.Run("empty string match", func(t *testing.T) { + re := regexp.MustCompile(`(?P.*)`) + prism := RegexNamedMatcher(re) + + result := prism.GetOption("") + assert.True(t, O.IsSome(result)) + + match := O.GetOrElse(F.Constant(NamedMatch{}))(result) + assert.Equal(t, "", match.Before) + assert.Equal(t, "", match.Full) + assert.Equal(t, "", match.After) + }) + + t.Run("multiple matches - only first", func(t *testing.T) { + re := regexp.MustCompile(`(?P\d+)`) + prism := RegexNamedMatcher(re) + + result := prism.GetOption("first 123 second 456") + assert.True(t, O.IsSome(result)) + + match := O.GetOrElse(F.Constant(NamedMatch{}))(result) + assert.Equal(t, "first ", match.Before) + assert.Equal(t, "123", match.Full) + assert.Equal(t, " second 456", match.After) + assert.Equal(t, "123", match.Groups["num"]) + }) +} + +// TestRegexNamedMatcherPrismLaws tests that RegexNamedMatcher satisfies prism laws +func TestRegexNamedMatcherPrismLaws(t *testing.T) { + re := regexp.MustCompile(`(?P\w+)@(?P\w+\.\w+)`) + prism := RegexNamedMatcher(re) + + t.Run("law 1: GetOption(ReverseGet(match)) reconstructs", func(t *testing.T) { + match := NamedMatch{ + Before: "email: ", + Full: "user@example.com", + Groups: map[string]string{ + "user": "user", + "domain": "example.com", + }, + After: "", + } + + str := prism.ReverseGet(match) + result := prism.GetOption(str) + + assert.True(t, O.IsSome(result)) + reconstructed := O.GetOrElse(F.Constant(NamedMatch{}))(result) + assert.Equal(t, match.Before, reconstructed.Before) + assert.Equal(t, match.Full, reconstructed.Full) + assert.Equal(t, match.After, reconstructed.After) + }) + + t.Run("law 2: ReverseGet(GetOption(s)) == s for matching strings", func(t *testing.T) { + original := "contact: admin@site.com" + extracted := prism.GetOption(original) + + if O.IsSome(extracted) { + match := O.GetOrElse(F.Constant(NamedMatch{}))(extracted) + reconstructed := prism.ReverseGet(match) + assert.Equal(t, original, reconstructed) + } + }) +} + +// TestRegexMatcherWithSet tests using Set with RegexMatcher +func TestRegexMatcherWithSet(t *testing.T) { + re := regexp.MustCompile(`\d+`) + prism := RegexMatcher(re) + + t.Run("set on matching string", func(t *testing.T) { + original := "price: 42 dollars" + + newMatch := Match{ + Before: "price: ", + Groups: []string{"100"}, + After: " dollars", + } + + setter := Set[string, Match](newMatch) + result := setter(prism)(original) + + assert.Equal(t, "price: 100 dollars", result) + }) + + t.Run("set on non-matching string returns original", func(t *testing.T) { + original := "no numbers" + + newMatch := Match{ + Before: "", + Groups: []string{"42"}, + After: "", + } + + setter := Set[string, Match](newMatch) + result := setter(prism)(original) + + assert.Equal(t, original, result) + }) +} + +// TestRegexNamedMatcherWithSet tests using Set with RegexNamedMatcher +func TestRegexNamedMatcherWithSet(t *testing.T) { + re := regexp.MustCompile(`(?P\w+)@(?P\w+\.\w+)`) + prism := RegexNamedMatcher(re) + + t.Run("set on matching string", func(t *testing.T) { + original := "email: user@example.com" + + newMatch := NamedMatch{ + Before: "email: ", + Full: "admin@newsite.com", + Groups: map[string]string{ + "user": "admin", + "domain": "newsite.com", + }, + After: "", + } + + setter := Set[string, NamedMatch](newMatch) + result := setter(prism)(original) + + assert.Equal(t, "email: admin@newsite.com", result) + }) + + t.Run("set on non-matching string returns original", func(t *testing.T) { + original := "no email here" + + newMatch := NamedMatch{ + Before: "", + Full: "test@test.com", + Groups: map[string]string{ + "user": "test", + "domain": "test.com", + }, + After: "", + } + + setter := Set[string, NamedMatch](newMatch) + result := setter(prism)(original) + + assert.Equal(t, original, result) + }) +} diff --git a/v2/samples/lens/gen.go b/v2/samples/lens/gen_lens.go similarity index 79% rename from v2/samples/lens/gen.go rename to v2/samples/lens/gen_lens.go index 5f0ff9b..624e954 100644 --- a/v2/samples/lens/gen.go +++ b/v2/samples/lens/gen_lens.go @@ -2,13 +2,13 @@ package lens // Code generated by go generate; DO NOT EDIT. // This file was generated by robots at -// 2025-11-07 16:13:10.2317216 +0100 CET m=+0.005378701 +// 2025-11-07 16:52:17.4935733 +0100 CET m=+0.003883901 import ( - F "github.com/IBM/fp-go/v2/function" L "github.com/IBM/fp-go/v2/optics/lens" LO "github.com/IBM/fp-go/v2/optics/lens/option" O "github.com/IBM/fp-go/v2/option" + I "github.com/IBM/fp-go/v2/optics/iso/option" option "github.com/IBM/fp-go/v2/optics/lens/option" ) @@ -30,7 +30,7 @@ type PersonRefLenses struct { // MakePersonLenses creates a new PersonLenses with lenses for all fields func MakePersonLenses() PersonLenses { - getOrElsePhone := O.GetOrElse(F.ConstNil[string]) + isoPhone := I.FromZero[*string]() return PersonLenses{ Name: L.MakeLens( func(s Person) string { return s.Name }, @@ -45,15 +45,15 @@ func MakePersonLenses() PersonLenses { func(s Person, v string) Person { s.Email = v; return s }, ), Phone: L.MakeLens( - func(s Person) O.Option[*string] { return O.FromNillable(s.Phone) }, - func(s Person, v O.Option[*string]) Person { s.Phone = getOrElsePhone(v); return s }, + func(s Person) O.Option[*string] { return isoPhone.Get(s.Phone) }, + func(s Person, v O.Option[*string]) Person { s.Phone = isoPhone.ReverseGet(v); return s }, ), } } // MakePersonRefLenses creates a new PersonRefLenses with lenses for all fields func MakePersonRefLenses() PersonRefLenses { - getOrElsePhone := O.GetOrElse(F.ConstNil[string]) + isoPhone := I.FromZero[*string]() return PersonRefLenses{ Name: L.MakeLensRef( func(s *Person) string { return s.Name }, @@ -68,8 +68,8 @@ func MakePersonRefLenses() PersonRefLenses { func(s *Person, v string) *Person { s.Email = v; return s }, ), Phone: L.MakeLensRef( - func(s *Person) O.Option[*string] { return O.FromNillable(s.Phone) }, - func(s *Person, v O.Option[*string]) *Person { s.Phone = getOrElsePhone(v); return s }, + func(s *Person) O.Option[*string] { return isoPhone.Get(s.Phone) }, + func(s *Person, v O.Option[*string]) *Person { s.Phone = isoPhone.ReverseGet(v); return s }, ), } } @@ -94,7 +94,7 @@ type AddressRefLenses struct { // MakeAddressLenses creates a new AddressLenses with lenses for all fields func MakeAddressLenses() AddressLenses { - getOrElseState := O.GetOrElse(F.ConstNil[string]) + isoState := I.FromZero[*string]() return AddressLenses{ Street: L.MakeLens( func(s Address) string { return s.Street }, @@ -113,15 +113,15 @@ func MakeAddressLenses() AddressLenses { func(s Address, v string) Address { s.Country = v; return s }, ), State: L.MakeLens( - func(s Address) O.Option[*string] { return O.FromNillable(s.State) }, - func(s Address, v O.Option[*string]) Address { s.State = getOrElseState(v); return s }, + func(s Address) O.Option[*string] { return isoState.Get(s.State) }, + func(s Address, v O.Option[*string]) Address { s.State = isoState.ReverseGet(v); return s }, ), } } // MakeAddressRefLenses creates a new AddressRefLenses with lenses for all fields func MakeAddressRefLenses() AddressRefLenses { - getOrElseState := O.GetOrElse(F.ConstNil[string]) + isoState := I.FromZero[*string]() return AddressRefLenses{ Street: L.MakeLensRef( func(s *Address) string { return s.Street }, @@ -140,8 +140,8 @@ func MakeAddressRefLenses() AddressRefLenses { func(s *Address, v string) *Address { s.Country = v; return s }, ), State: L.MakeLensRef( - func(s *Address) O.Option[*string] { return O.FromNillable(s.State) }, - func(s *Address, v O.Option[*string]) *Address { s.State = getOrElseState(v); return s }, + func(s *Address) O.Option[*string] { return isoState.Get(s.State) }, + func(s *Address, v O.Option[*string]) *Address { s.State = isoState.ReverseGet(v); return s }, ), } } @@ -164,7 +164,7 @@ type CompanyRefLenses struct { // MakeCompanyLenses creates a new CompanyLenses with lenses for all fields func MakeCompanyLenses() CompanyLenses { - getOrElseWebsite := O.GetOrElse(F.ConstNil[string]) + isoWebsite := I.FromZero[*string]() return CompanyLenses{ Name: L.MakeLens( func(s Company) string { return s.Name }, @@ -179,15 +179,15 @@ func MakeCompanyLenses() CompanyLenses { func(s Company, v Person) Company { s.CEO = v; return s }, ), Website: L.MakeLens( - func(s Company) O.Option[*string] { return O.FromNillable(s.Website) }, - func(s Company, v O.Option[*string]) Company { s.Website = getOrElseWebsite(v); return s }, + func(s Company) O.Option[*string] { return isoWebsite.Get(s.Website) }, + func(s Company, v O.Option[*string]) Company { s.Website = isoWebsite.ReverseGet(v); return s }, ), } } // MakeCompanyRefLenses creates a new CompanyRefLenses with lenses for all fields func MakeCompanyRefLenses() CompanyRefLenses { - getOrElseWebsite := O.GetOrElse(F.ConstNil[string]) + isoWebsite := I.FromZero[*string]() return CompanyRefLenses{ Name: L.MakeLensRef( func(s *Company) string { return s.Name }, @@ -202,8 +202,8 @@ func MakeCompanyRefLenses() CompanyRefLenses { func(s *Company, v Person) *Company { s.CEO = v; return s }, ), Website: L.MakeLensRef( - func(s *Company) O.Option[*string] { return O.FromNillable(s.Website) }, - func(s *Company, v O.Option[*string]) *Company { s.Website = getOrElseWebsite(v); return s }, + func(s *Company) O.Option[*string] { return isoWebsite.Get(s.Website) }, + func(s *Company, v O.Option[*string]) *Company { s.Website = isoWebsite.ReverseGet(v); return s }, ), } } @@ -211,39 +211,41 @@ func MakeCompanyRefLenses() CompanyRefLenses { // CheckOptionLenses provides lenses for accessing fields of CheckOption type CheckOptionLenses struct { Name L.Lens[CheckOption, option.Option[string]] - Value L.Lens[CheckOption, string] + Value LO.LensO[CheckOption, string] } // CheckOptionRefLenses provides lenses for accessing fields of CheckOption via a reference to CheckOption type CheckOptionRefLenses struct { Name L.Lens[*CheckOption, option.Option[string]] - Value L.Lens[*CheckOption, string] + Value LO.LensO[*CheckOption, string] } // MakeCheckOptionLenses creates a new CheckOptionLenses with lenses for all fields func MakeCheckOptionLenses() CheckOptionLenses { + isoValue := I.FromZero[string]() return CheckOptionLenses{ Name: L.MakeLens( func(s CheckOption) option.Option[string] { return s.Name }, func(s CheckOption, v option.Option[string]) CheckOption { s.Name = v; return s }, ), Value: L.MakeLens( - func(s CheckOption) string { return s.Value }, - func(s CheckOption, v string) CheckOption { s.Value = v; return s }, + func(s CheckOption) O.Option[string] { return isoValue.Get(s.Value) }, + func(s CheckOption, v O.Option[string]) CheckOption { s.Value = isoValue.ReverseGet(v); return s }, ), } } // MakeCheckOptionRefLenses creates a new CheckOptionRefLenses with lenses for all fields func MakeCheckOptionRefLenses() CheckOptionRefLenses { + isoValue := I.FromZero[string]() return CheckOptionRefLenses{ Name: L.MakeLensRef( func(s *CheckOption) option.Option[string] { return s.Name }, func(s *CheckOption, v option.Option[string]) *CheckOption { s.Name = v; return s }, ), Value: L.MakeLensRef( - func(s *CheckOption) string { return s.Value }, - func(s *CheckOption, v string) *CheckOption { s.Value = v; return s }, + func(s *CheckOption) O.Option[string] { return isoValue.Get(s.Value) }, + func(s *CheckOption, v O.Option[string]) *CheckOption { s.Value = isoValue.ReverseGet(v); return s }, ), } }