From f4192a6fb8933316a8a27cd44d5cce91914c9512 Mon Sep 17 00:00:00 2001 From: Manfred Touron <94029+moul@users.noreply.github.com> Date: Tue, 11 Feb 2025 17:23:20 +0100 Subject: [PATCH] feat(examples): add p/moul/dynreplacer (#3710) See https://github.com/gnolang/gno/issues/3249#issuecomment-2646045913 Expected usage: homepages that rely on other realms for parts of their rendered content. This approach simplifies updating the content layout, enables freeform content, and allows users to toggle blocks, change their order, or modify the layout itself (including titles, delimiters, etc.). --------- Signed-off-by: moul <94029+moul@users.noreply.github.com> --- .../p/moul/dynreplacer/dynreplacer.gno | 111 +++++++++ .../p/moul/dynreplacer/dynreplacer_test.gno | 235 ++++++++++++++++++ examples/gno.land/p/moul/dynreplacer/gno.mod | 1 + 3 files changed, 347 insertions(+) create mode 100644 examples/gno.land/p/moul/dynreplacer/dynreplacer.gno create mode 100644 examples/gno.land/p/moul/dynreplacer/dynreplacer_test.gno create mode 100644 examples/gno.land/p/moul/dynreplacer/gno.mod diff --git a/examples/gno.land/p/moul/dynreplacer/dynreplacer.gno b/examples/gno.land/p/moul/dynreplacer/dynreplacer.gno new file mode 100644 index 00000000000..ea9687b7570 --- /dev/null +++ b/examples/gno.land/p/moul/dynreplacer/dynreplacer.gno @@ -0,0 +1,111 @@ +// Package dynreplacer provides a simple template engine for handling dynamic +// content replacement. It is similar to strings.Replacer but with lazy +// execution of replacements, making it more optimization-friendly in several +// cases. While strings.Replacer requires all replacement values to be computed +// upfront, dynreplacer only executes the callback functions for placeholders +// that actually exist in the template, avoiding unnecessary computations. +// +// The package ensures efficient, non-recursive replacement of placeholders in a +// single pass. This lazy evaluation approach is particularly beneficial when: +// - Some replacement values are expensive to compute +// - Not all placeholders are guaranteed to be present in the template +// - Templates are reused with different content +// +// Example usage: +// +// r := dynreplacer.New( +// dynreplacer.Pair{":name:", func() string { return "World" }}, +// dynreplacer.Pair{":greeting:", func() string { return "Hello" }}, +// ) +// result := r.Replace("Hello :name:!") // Returns "Hello World!" +// +// The replacer caches computed values, so subsequent calls with the same +// placeholder will reuse the cached value instead of executing the callback +// again: +// +// r := dynreplacer.New() +// r.RegisterCallback(":expensive:", func() string { return "computed" }) +// r.Replace("Value1: :expensive:") // Computes the value +// r.Replace("Value2: :expensive:") // Uses cached value +// r.ClearCache() // Force re-computation on next use +package dynreplacer + +import ( + "strings" +) + +// Replacer manages dynamic placeholders, their associated functions, and cached +// values. +type Replacer struct { + callbacks map[string]func() string + cachedValues map[string]string +} + +// Pair represents a placeholder and its callback function +type Pair struct { + Placeholder string + Callback func() string +} + +// New creates a new Replacer instance with optional initial replacements. +// It accepts pairs where each pair consists of a placeholder string and +// its corresponding callback function. +// +// Example: +// +// New( +// Pair{":name:", func() string { return "World" }}, +// Pair{":greeting:", func() string { return "Hello" }}, +// ) +func New(pairs ...Pair) *Replacer { + r := &Replacer{ + callbacks: make(map[string]func() string), + cachedValues: make(map[string]string), + } + + for _, pair := range pairs { + r.RegisterCallback(pair.Placeholder, pair.Callback) + } + + return r +} + +// RegisterCallback associates a placeholder with a function to generate its +// content. +func (r *Replacer) RegisterCallback(placeholder string, callback func() string) { + r.callbacks[placeholder] = callback +} + +// Replace processes the given layout, replacing placeholders with cached or +// newly computed values. +func (r *Replacer) Replace(layout string) string { + replacements := []string{} + + // Check for placeholders and compute/retrieve values + hasReplacements := false + for placeholder, callback := range r.callbacks { + if strings.Contains(layout, placeholder) { + value, exists := r.cachedValues[placeholder] + if !exists { + value = callback() + r.cachedValues[placeholder] = value + } + replacements = append(replacements, placeholder, value) + hasReplacements = true + } + } + + // If no replacements were found, return the original layout + if !hasReplacements { + return layout + } + + // Create a strings.Replacer with all computed replacements + replacer := strings.NewReplacer(replacements...) + return replacer.Replace(layout) +} + +// ClearCache clears all cached values, forcing re-computation on next Replace. +func (r *Replacer) ClearCache() { + r.cachedValues = make(map[string]string) +} diff --git a/examples/gno.land/p/moul/dynreplacer/dynreplacer_test.gno b/examples/gno.land/p/moul/dynreplacer/dynreplacer_test.gno new file mode 100644 index 00000000000..51235a28950 --- /dev/null +++ b/examples/gno.land/p/moul/dynreplacer/dynreplacer_test.gno @@ -0,0 +1,235 @@ +package dynreplacer + +import ( + "strings" + "testing" + + "gno.land/p/demo/uassert" +) + +func TestNew(t *testing.T) { + tests := []struct { + name string + pairs []Pair + }{ + { + name: "empty constructor", + pairs: []Pair{}, + }, + { + name: "single pair", + pairs: []Pair{ + {":name:", func() string { return "World" }}, + }, + }, + { + name: "multiple pairs", + pairs: []Pair{ + {":greeting:", func() string { return "Hello" }}, + {":name:", func() string { return "World" }}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := New(tt.pairs...) + uassert.True(t, r.callbacks != nil, "callbacks map should be initialized") + uassert.True(t, r.cachedValues != nil, "cachedValues map should be initialized") + + // Verify all callbacks were registered + for _, pair := range tt.pairs { + _, exists := r.callbacks[pair.Placeholder] + uassert.True(t, exists, "callback should be registered for "+pair.Placeholder) + } + }) + } +} + +func TestReplace(t *testing.T) { + tests := []struct { + name string + layout string + setup func(*Replacer) + expected string + }{ + { + name: "empty layout", + layout: "", + setup: func(r *Replacer) {}, + expected: "", + }, + { + name: "single replacement", + layout: "Hello :name:!", + setup: func(r *Replacer) { + r.RegisterCallback(":name:", func() string { return "World" }) + }, + expected: "Hello World!", + }, + { + name: "multiple replacements", + layout: ":greeting: :name:!", + setup: func(r *Replacer) { + r.RegisterCallback(":greeting:", func() string { return "Hello" }) + r.RegisterCallback(":name:", func() string { return "World" }) + }, + expected: "Hello World!", + }, + { + name: "no recursive replacement", + layout: ":outer:", + setup: func(r *Replacer) { + r.RegisterCallback(":outer:", func() string { return ":inner:" }) + r.RegisterCallback(":inner:", func() string { return "content" }) + }, + expected: ":inner:", + }, + { + name: "unused callbacks", + layout: "Hello :name:!", + setup: func(r *Replacer) { + r.RegisterCallback(":name:", func() string { return "World" }) + r.RegisterCallback(":unused:", func() string { return "Never Called" }) + }, + expected: "Hello World!", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := New() + tt.setup(r) + result := r.Replace(tt.layout) + uassert.Equal(t, tt.expected, result) + }) + } +} + +func TestCaching(t *testing.T) { + r := New() + callCount := 0 + r.RegisterCallback(":expensive:", func() string { + callCount++ + return "computed" + }) + + layout := "Value: :expensive:" + + // First call should compute + result1 := r.Replace(layout) + uassert.Equal(t, "Value: computed", result1) + uassert.Equal(t, 1, callCount) + + // Second call should use cache + result2 := r.Replace(layout) + uassert.Equal(t, "Value: computed", result2) + uassert.Equal(t, 1, callCount) + + // After clearing cache, should recompute + r.ClearCache() + result3 := r.Replace(layout) + uassert.Equal(t, "Value: computed", result3) + uassert.Equal(t, 2, callCount) +} + +func TestComplexExample(t *testing.T) { + layout := ` + # Welcome to gno.land + + ## Blog + :latest-blogposts: + + ## Events + :next-events: + + ## Awesome Gno + :awesome-gno: + ` + + r := New( + Pair{":latest-blogposts:", func() string { return "Latest blog posts content here" }}, + Pair{":next-events:", func() string { return "Upcoming events listed here" }}, + Pair{":awesome-gno:", func() string { return ":latest-blogposts: (This should NOT be replaced again)" }}, + ) + + result := r.Replace(layout) + + // Check that original placeholders are replaced + uassert.True(t, !strings.Contains(result, ":latest-blogposts:\n"), "':latest-blogposts:' placeholder should be replaced") + uassert.True(t, !strings.Contains(result, ":next-events:\n"), "':next-events:' placeholder should be replaced") + uassert.True(t, !strings.Contains(result, ":awesome-gno:\n"), "':awesome-gno:' placeholder should be replaced") + + // Check that the replacement content is present + uassert.True(t, strings.Contains(result, "Latest blog posts content here"), "Blog posts content should be present") + uassert.True(t, strings.Contains(result, "Upcoming events listed here"), "Events content should be present") + uassert.True(t, strings.Contains(result, ":latest-blogposts: (This should NOT be replaced again)"), + "Nested placeholder should not be replaced") +} + +func TestEdgeCases(t *testing.T) { + tests := []struct { + name string + layout string + setup func(*Replacer) + expected string + }{ + { + name: "empty string placeholder", + layout: "Hello :", + setup: func(r *Replacer) { + r.RegisterCallback("", func() string { return "World" }) + }, + expected: "WorldHWorldeWorldlWorldlWorldoWorld World:World", + }, + { + name: "overlapping placeholders", + layout: "Hello :name::greeting:", + setup: func(r *Replacer) { + r.RegisterCallback(":name:", func() string { return "World" }) + r.RegisterCallback(":greeting:", func() string { return "Hi" }) + r.RegisterCallback(":name::greeting:", func() string { return "Should not match" }) + }, + expected: "Hello WorldHi", + }, + { + name: "replacement order", + layout: ":a::b::c:", + setup: func(r *Replacer) { + r.RegisterCallback(":c:", func() string { return "3" }) + r.RegisterCallback(":b:", func() string { return "2" }) + r.RegisterCallback(":a:", func() string { return "1" }) + }, + expected: "123", + }, + { + name: "special characters in placeholders", + layout: "Hello :$name#123:!", + setup: func(r *Replacer) { + r.RegisterCallback(":$name#123:", func() string { return "World" }) + }, + expected: "Hello World!", + }, + { + name: "multiple occurrences of same placeholder", + layout: ":name: and :name: again", + setup: func(r *Replacer) { + callCount := 0 + r.RegisterCallback(":name:", func() string { + callCount++ + return "World" + }) + }, + expected: "World and World again", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := New() + tt.setup(r) + result := r.Replace(tt.layout) + uassert.Equal(t, tt.expected, result) + }) + } +} diff --git a/examples/gno.land/p/moul/dynreplacer/gno.mod b/examples/gno.land/p/moul/dynreplacer/gno.mod new file mode 100644 index 00000000000..9e196721f24 --- /dev/null +++ b/examples/gno.land/p/moul/dynreplacer/gno.mod @@ -0,0 +1 @@ +module gno.land/p/moul/dynreplacer