diff --git a/README.md b/README.md index 6bbdaf6..2f7d34f 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ $ go run . Hello World -------------------------------------------------------------------------------- -Go Version: go1.24.4 +Go Version: go1.24.9 ``` @@ -160,7 +160,7 @@ $ go run . Hello World -------------------------------------------------------------------------------- -Go Version: go1.24.4 +Go Version: go1.24.9 ``` @@ -189,7 +189,7 @@ $ go run . Hello World -------------------------------------------------------------------------------- -Go Version: go1.24.4 +Go Version: go1.24.9 ``` @@ -219,7 +219,7 @@ $ go run . ./main.go:7:6: undefined: fmt.Prin -------------------------------------------------------------------------------- -Go Version: go1.24.4 +Go Version: go1.24.9 ``` @@ -256,7 +256,7 @@ type Context interface{ ... } func WithoutCancel(parent Context) Context -------------------------------------------------------------------------------- -Go Version: go1.24.4 +Go Version: go1.24.9 ``` @@ -279,7 +279,7 @@ func WithCancel(parent Context) (ctx Context, cancel CancelFunc) call cancel as soon as the operations running in this Context complete. -------------------------------------------------------------------------------- -Go Version: go1.24.4 +Go Version: go1.24.9 ``` diff --git a/document.go b/document.go index 10602a8..e59420b 100644 --- a/document.go +++ b/document.go @@ -32,12 +32,31 @@ func (doc *Document) MarshalJSON() ([]byte, error) { return nil, ErrIsNil("document") } + // Create sorted maps to ensure deterministic JSON output + sortedRules := make(map[string]string) + if doc.Snippets.rules != nil { + for k, v := range doc.Snippets.rules { + sortedRules[k] = v + } + } + + sortedSnippets := make(map[string]map[string]Snippet) + if doc.Snippets.snippets != nil { + for k, v := range doc.Snippets.snippets { + innerMap := make(map[string]Snippet) + for ik, iv := range v { + innerMap[ik] = iv + } + sortedSnippets[k] = innerMap + } + } + snips := struct { Rules map[string]string `json:"rules,omitempty"` Snippets map[string]map[string]Snippet `json:"snippets,omitempty"` }{ - Rules: doc.Snippets.rules, - Snippets: doc.Snippets.snippets, + Rules: sortedRules, + Snippets: sortedSnippets, } x := struct { diff --git a/document_test.go b/document_test.go index ce2f060..78a6ab9 100644 --- a/document_test.go +++ b/document_test.go @@ -1,11 +1,14 @@ package hype import ( + "bytes" "context" + "encoding/json" "errors" "io/fs" "strings" "testing" + "testing/fstest" "time" "github.com/stretchr/testify/require" @@ -131,3 +134,367 @@ func Test_Document_Pages_NoPages(t *testing.T) { r.Len(pages, 1) } + +// Test_Document_JSON_Determinism verifies that marshaling the same document to JSON +// produces identical output every time, ensuring predictable/deterministic behavior. +func Test_Document_JSON_Determinism(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + content string + runs int + }{ + { + name: "basic document", + content: ` +

Test Title

+

This is a test paragraph.

+
`, + runs: 10, + }, + { + name: "document with attributes", + content: ` +

Test Title

+

Paragraph with attributes.

+
Content
+
`, + runs: 10, + }, + { + name: "document with code snippets", + content: ` +

Code Example

+

+package main
+
+func main() {
+    println("Hello, World!")
+}
+
+
`, + runs: 10, + }, + { + name: "complex document", + content: ` +

Complex Document

+

Introduction paragraph.

+
+

Section 1

+

Some content here.

+
+
+

Code Section

+
func test() {}
+
+
`, + runs: 10, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + r := require.New(t) + + // Parse the document multiple times and marshal to JSON + var jsonOutputs [][]byte + for i := 0; i < tc.runs; i++ { + cab := fstest.MapFS{ + "test.md": &fstest.MapFile{ + Data: []byte(tc.content), + }, + } + + p := NewParser(cab) + // Use a fixed ID generator to ensure deterministic IDs + p.DocIDGen = func() (string, error) { + return "test-doc-id", nil + } + + doc, err := p.ParseFile("test.md") + r.NoError(err) + + jsonData, err := json.Marshal(doc) + r.NoError(err) + jsonOutputs = append(jsonOutputs, jsonData) + } + + // Verify all JSON outputs are identical + firstOutput := jsonOutputs[0] + for i := 1; i < len(jsonOutputs); i++ { + if !bytes.Equal(firstOutput, jsonOutputs[i]) { + t.Errorf("Run %d produced different JSON output:\nFirst:\n%s\n\nRun %d:\n%s\n", + 1, string(firstOutput), i+1, string(jsonOutputs[i])) + } + } + }) + } +} + +// Test_Document_JSON_Determinism_WithExecution tests that JSON output remains +// deterministic even after document execution. +func Test_Document_JSON_Determinism_WithExecution(t *testing.T) { + t.Parallel() + + content := ` +

Test With Execution

+

Content paragraph.

+
` + + runs := 10 + var jsonOutputs [][]byte + + for i := 0; i < runs; i++ { + cab := fstest.MapFS{ + "test.md": &fstest.MapFile{ + Data: []byte(content), + }, + } + + p := NewParser(cab) + // Use fixed ID and time for determinism + p.DocIDGen = func() (string, error) { + return "test-doc-id", nil + } + p.NowFn = func() time.Time { + return time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + } + + doc, err := p.ParseFile("test.md") + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) + err = doc.Execute(ctx) + cancel() + require.NoError(t, err) + + jsonData, err := json.Marshal(doc) + require.NoError(t, err) + jsonOutputs = append(jsonOutputs, jsonData) + } + + // Verify all JSON outputs are identical + firstOutput := jsonOutputs[0] + for i := 1; i < len(jsonOutputs); i++ { + if !bytes.Equal(firstOutput, jsonOutputs[i]) { + t.Errorf("Run %d produced different JSON output:\nFirst:\n%s\n\nRun %d:\n%s\n", + 1, string(firstOutput), i+1, string(jsonOutputs[i])) + } + } +} + +// Test_Document_JSON_Determinism_RealWorld tests with a more realistic document +// that includes multiple features. +func Test_Document_JSON_Determinism_RealWorld(t *testing.T) { + t.Parallel() + r := require.New(t) + + // Create a test filesystem with files + cab := fstest.MapFS{ + "hype.md": &fstest.MapFile{ + Data: []byte(` +

Real World Test

+

This is a real world test document.

+
+

Section 1

+

Content for section 1.

+
+
+

Section 2

+

+package main
+
+import "fmt"
+
+func main() {
+    fmt.Println("Hello, World!")
+}
+
+
+
`), + }, + } + + runs := 10 + var jsonOutputs [][]byte + + for i := 0; i < runs; i++ { + p := NewParser(cab) + p.Root = "/test" + // Use fixed ID for determinism + p.DocIDGen = func() (string, error) { + return "test-doc-id", nil + } + p.NowFn = func() time.Time { + return time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + } + + // Add some vars + p.Vars.Set("var1", "value1") + p.Vars.Set("var2", "value2") + + doc, err := p.ParseFile("hype.md") + r.NoError(err) + + jsonData, err := json.Marshal(doc) + r.NoError(err) + jsonOutputs = append(jsonOutputs, jsonData) + } + + // Verify all JSON outputs are identical + firstOutput := jsonOutputs[0] + for i := 1; i < len(jsonOutputs); i++ { + if !bytes.Equal(firstOutput, jsonOutputs[i]) { + t.Errorf("Run %d produced different JSON output:\nFirst:\n%s\n\nRun %d:\n%s\n", + 1, prettifyJSON(firstOutput), i+1, prettifyJSON(jsonOutputs[i])) + } + } +} + +// Test_Parser_JSON_Determinism tests that Parser marshaling is deterministic. +func Test_Parser_JSON_Determinism(t *testing.T) { + t.Parallel() + r := require.New(t) + + runs := 10 + var jsonOutputs [][]byte + + for i := 0; i < runs; i++ { + cab := fstest.MapFS{} + p := NewParser(cab) + p.Root = "/test/root" + p.Section = 1 + + // Add some vars in different orders + p.Vars.Set("key1", "value1") + p.Vars.Set("key2", "value2") + p.Vars.Set("key3", "value3") + + jsonData, err := json.Marshal(p) + r.NoError(err) + jsonOutputs = append(jsonOutputs, jsonData) + } + + // Verify all JSON outputs are identical + firstOutput := jsonOutputs[0] + for i := 1; i < len(jsonOutputs); i++ { + r.Equal(firstOutput, jsonOutputs[i], + "Parser JSON output should be deterministic") + } +} + +// Test_Element_JSON_Determinism tests that Element marshaling is deterministic. +func Test_Element_JSON_Determinism(t *testing.T) { + t.Parallel() + r := require.New(t) + + runs := 10 + var jsonOutputs [][]byte + + for i := 0; i < runs; i++ { + el := NewEl("div", nil) + el.Set("id", "test") + el.Set("class", "container") + el.Set("data-foo", "bar") + el.Set("data-baz", "qux") + + jsonData, err := json.Marshal(el) + r.NoError(err) + jsonOutputs = append(jsonOutputs, jsonData) + } + + // Verify all JSON outputs are identical + firstOutput := jsonOutputs[0] + for i := 1; i < len(jsonOutputs); i++ { + r.Equal(firstOutput, jsonOutputs[i], + "Element JSON output should be deterministic") + } +} + +// Test_Snippets_JSON_Determinism tests that Snippets marshaling is deterministic. +func Test_Snippets_JSON_Determinism(t *testing.T) { + t.Parallel() + r := require.New(t) + + runs := 10 + var jsonOutputs [][]byte + + for i := 0; i < runs; i++ { + snips := &Snippets{} + snips.init() + + // Add some rules + snips.Add(".go", "// %s") + snips.Add(".rb", "# %s") + snips.Add(".js", "// %s") + + // Simulate adding snippets + snips.mu.Lock() + snips.snippets["file1.go"] = map[string]Snippet{ + "snippet1": {Name: "snippet1", Content: "func test() {}", Lang: "go"}, + "snippet2": {Name: "snippet2", Content: "func main() {}", Lang: "go"}, + } + snips.snippets["file2.rb"] = map[string]Snippet{ + "snippet3": {Name: "snippet3", Content: "def test; end", Lang: "rb"}, + } + snips.mu.Unlock() + + jsonData, err := json.Marshal(snips) + r.NoError(err) + jsonOutputs = append(jsonOutputs, jsonData) + } + + // Verify all JSON outputs are identical + firstOutput := jsonOutputs[0] + for i := 1; i < len(jsonOutputs); i++ { + r.Equal(firstOutput, jsonOutputs[i], + "Snippets JSON output should be deterministic") + } +} + +// Benchmark_Document_JSON_Marshal benchmarks JSON marshaling performance +func Benchmark_Document_JSON_Marshal(b *testing.B) { + cab := fstest.MapFS{ + "test.md": &fstest.MapFile{ + Data: []byte(` +

Benchmark Test

+

This is a benchmark test.

+
+

Section

+

Content here.

+
+
`), + }, + } + + p := NewParser(cab) + p.DocIDGen = func() (string, error) { + return "bench-doc-id", nil + } + + doc, err := p.ParseFile("test.md") + if err != nil { + b.Fatal(err) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := json.Marshal(doc) + if err != nil { + b.Fatal(err) + } + } +} + +// prettifyJSON formats JSON for better error output +func prettifyJSON(data []byte) string { + var out bytes.Buffer + if err := json.Indent(&out, data, "", " "); err != nil { + return string(data) + } + return out.String() +} diff --git a/element.go b/element.go index 5004f90..5a162f3 100644 --- a/element.go +++ b/element.go @@ -55,7 +55,14 @@ func (el *Element) JSONMap() (map[string]any, error) { } if el.Attributes.Len() > 0 { - m["attributes"] = el.Attributes + // Convert syncx.Map to regular map for deterministic JSON marshaling + // Go's json package will sort map keys lexicographically + attrs := make(map[string]string) + el.Attributes.Range(func(k, v string) bool { + attrs[k] = v + return true + }) + m["attributes"] = attrs } hn := el.HTMLNode diff --git a/parser.go b/parser.go index 9ab9d0a..b2da315 100644 --- a/parser.go +++ b/parser.go @@ -47,6 +47,14 @@ func (p *Parser) MarshalJSON() ([]byte, error) { p.mu.RLock() defer p.mu.RUnlock() + // Convert syncx.Map to regular map for deterministic JSON marshaling + // Go's json package will sort map keys lexicographically + vars := make(map[string]any) + p.Vars.Range(func(k string, v any) bool { + vars[k] = v + return true + }) + x := struct { Type string `json:"type,omitempty"` Root string `json:"root,omitempty"` @@ -59,7 +67,7 @@ func (p *Parser) MarshalJSON() ([]byte, error) { Root: p.Root, DisablePages: p.DisablePages, Section: p.Section, - Vars: p.Vars.Map(), + Vars: vars, Contents: string(p.Contents), } diff --git a/snippet.go b/snippet.go index d31b76b..75cda1f 100644 --- a/snippet.go +++ b/snippet.go @@ -77,13 +77,34 @@ func (sm *Snippets) MarshalJSON() ([]byte, error) { return nil, ErrIsNil("snippets") } + // Create sorted maps to ensure deterministic JSON output + sortedRules := make(map[string]string) + if sm.rules != nil { + for k, v := range sm.rules { + sortedRules[k] = v + } + } + + sortedSnippets := make(map[string]map[string]Snippet) + if sm.snippets != nil { + for k, v := range sm.snippets { + innerMap := make(map[string]Snippet) + for ik, iv := range v { + innerMap[ik] = iv + } + sortedSnippets[k] = innerMap + } + } + + // Use json.Marshal with sorted map keys + // Go's json package always sorts map keys lexicographically m := struct { Rules map[string]string `json:"rules,omitempty"` Snippets map[string]map[string]Snippet `json:"snippets,omitempty"` Type string `json:"type,omitempty"` }{ - Rules: sm.rules, - Snippets: sm.snippets, + Rules: sortedRules, + Snippets: sortedSnippets, Type: toType(sm), }