Skip to content

Commit

Permalink
feat(examples): add p/moul/dynreplacer (#3710)
Browse files Browse the repository at this point in the history
See #3249 (comment)

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 <[email protected]>
  • Loading branch information
moul authored Feb 11, 2025
1 parent 7ca5ed2 commit f4192a6
Show file tree
Hide file tree
Showing 3 changed files with 347 additions and 0 deletions.
111 changes: 111 additions & 0 deletions examples/gno.land/p/moul/dynreplacer/dynreplacer.gno
Original file line number Diff line number Diff line change
@@ -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)
}
235 changes: 235 additions & 0 deletions examples/gno.land/p/moul/dynreplacer/dynreplacer_test.gno
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
1 change: 1 addition & 0 deletions examples/gno.land/p/moul/dynreplacer/gno.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module gno.land/p/moul/dynreplacer

0 comments on commit f4192a6

Please sign in to comment.