From 3a88f7fabd5488f3fe2cd7abf439fb1958adb1db Mon Sep 17 00:00:00 2001 From: moul <94029+moul@users.noreply.github.com> Date: Sun, 8 Dec 2024 13:04:10 +0100 Subject: [PATCH 1/2] feat(examples): add p/moul/template Signed-off-by: moul <94029+moul@users.noreply.github.com> --- examples/gno.land/p/moul/md/md.gno | 58 +- examples/gno.land/p/moul/md/md_test.gno | 158 ++- examples/gno.land/p/moul/template/gno.mod | 1 + .../gno.land/p/moul/template/template.gno | 659 ++++++++++ .../p/moul/template/template_test.gno | 289 +++++ .../gno.land/p/moul/template/z1_filetest.gno | 130 ++ examples/gno.land/p/moul/typeutil/gno.mod | 1 + .../gno.land/p/moul/typeutil/typeutil.gno | 715 +++++++++++ .../p/moul/typeutil/typeutil_test.gno | 1075 +++++++++++++++++ 9 files changed, 3002 insertions(+), 84 deletions(-) create mode 100644 examples/gno.land/p/moul/template/gno.mod create mode 100644 examples/gno.land/p/moul/template/template.gno create mode 100644 examples/gno.land/p/moul/template/template_test.gno create mode 100644 examples/gno.land/p/moul/template/z1_filetest.gno create mode 100644 examples/gno.land/p/moul/typeutil/gno.mod create mode 100644 examples/gno.land/p/moul/typeutil/typeutil.gno create mode 100644 examples/gno.land/p/moul/typeutil/typeutil_test.gno diff --git a/examples/gno.land/p/moul/md/md.gno b/examples/gno.land/p/moul/md/md.gno index 61d6948b997..12b6547f44d 100644 --- a/examples/gno.land/p/moul/md/md.gno +++ b/examples/gno.land/p/moul/md/md.gno @@ -17,13 +17,13 @@ import ( // Bold returns bold text for markdown. // Example: Bold("foo") => "**foo**" func Bold(text string) string { - return "**" + text + "**" + return "**" + strings.TrimSpace(text) + "**" } // Italic returns italicized text for markdown. // Example: Italic("foo") => "*foo*" func Italic(text string) string { - return "*" + text + "*" + return "*" + strings.TrimSpace(text) + "*" } // Strikethrough returns strikethrough text for markdown. @@ -35,37 +35,37 @@ func Strikethrough(text string) string { // H1 returns a level 1 header for markdown. // Example: H1("foo") => "# foo\n" func H1(text string) string { - return "# " + text + "\n" + return "# " + strings.TrimSpace(text) } // H2 returns a level 2 header for markdown. // Example: H2("foo") => "## foo\n" func H2(text string) string { - return "## " + text + "\n" + return "## " + strings.TrimSpace(text) } // H3 returns a level 3 header for markdown. // Example: H3("foo") => "### foo\n" func H3(text string) string { - return "### " + text + "\n" + return "### " + strings.TrimSpace(text) } // H4 returns a level 4 header for markdown. // Example: H4("foo") => "#### foo\n" func H4(text string) string { - return "#### " + text + "\n" + return "#### " + strings.TrimSpace(text) } // H5 returns a level 5 header for markdown. // Example: H5("foo") => "##### foo\n" func H5(text string) string { - return "##### " + text + "\n" + return "##### " + strings.TrimSpace(text) } // H6 returns a level 6 header for markdown. // Example: H6("foo") => "###### foo\n" func H6(text string) string { - return "###### " + text + "\n" + return "###### " + strings.TrimSpace(text) } // BulletList returns a bullet list for markdown. @@ -83,9 +83,9 @@ func BulletList(items []string) string { func BulletItem(item string) string { var sb strings.Builder lines := strings.Split(item, "\n") - sb.WriteString("- " + lines[0] + "\n") + sb.WriteString("- " + strings.TrimSpace(lines[0]) + "\n") for _, line := range lines[1:] { - sb.WriteString(" " + line + "\n") + sb.WriteString(" " + strings.TrimSpace(line) + "\n") } return sb.String() } @@ -96,9 +96,9 @@ func OrderedList(items []string) string { var sb strings.Builder for i, item := range items { lines := strings.Split(item, "\n") - sb.WriteString(strconv.Itoa(i+1) + ". " + lines[0] + "\n") + sb.WriteString(strconv.Itoa(i+1) + ". " + strings.TrimSpace(lines[0]) + "\n") for _, line := range lines[1:] { - sb.WriteString(" " + line + "\n") + sb.WriteString(" " + strings.TrimSpace(line) + "\n") } } return sb.String() @@ -123,19 +123,19 @@ func TodoItem(item string, done bool) string { checkbox = "x" } lines := strings.Split(item, "\n") - sb.WriteString("- [" + checkbox + "] " + lines[0] + "\n") + sb.WriteString("- [" + checkbox + "] " + strings.TrimSpace(lines[0]) + "\n") for _, line := range lines[1:] { - sb.WriteString(" " + line + "\n") + sb.WriteString(" " + strings.TrimSpace(line) + "\n") } return sb.String() } -// Nested prefixes each line with a given prefix, enabling nested lists. -// Example: Nested("- foo\n- bar", " ") => " - foo\n - bar\n" +// Nested prefixes each line with a given prefix, preserving existing spaces. +// Example: Nested("- foo\n- bar", " ") => " - foo\n - bar" func Nested(content, prefix string) string { lines := strings.Split(content, "\n") for i := range lines { - if strings.TrimSpace(lines[i]) != "" { + if lines[i] != "" { lines[i] = prefix + lines[i] } } @@ -145,10 +145,13 @@ func Nested(content, prefix string) string { // Blockquote returns a blockquote for markdown. // Example: Blockquote("foo\nbar") => "> foo\n> bar\n" func Blockquote(text string) string { - lines := strings.Split(text, "\n") + lines := strings.Split(strings.TrimSpace(text), "\n") var sb strings.Builder - for _, line := range lines { - sb.WriteString("> " + line + "\n") + for i, line := range lines { + sb.WriteString("> " + strings.TrimSpace(line)) + if i < len(lines)-1 { + sb.WriteString("\n") + } } return sb.String() } @@ -180,7 +183,7 @@ func HorizontalRule() string { // Link returns a hyperlink for markdown. // Example: Link("foo", "http://example.com") => "[foo](http://example.com)" func Link(text, url string) string { - return "[" + EscapeText(text) + "](" + url + ")" + return "[" + strings.TrimSpace(text) + "](" + url + ")" } // InlineImageWithLink creates an inline image wrapped in a hyperlink for markdown. @@ -192,7 +195,7 @@ func InlineImageWithLink(altText, imageUrl, linkUrl string) string { // Image returns an image for markdown. // Example: Image("foo", "http://example.com") => "![foo](http://example.com)" func Image(altText, url string) string { - return "![" + EscapeText(altText) + "](" + url + ")" + return "![" + strings.TrimSpace(altText) + "](" + url + ")" } // Footnote returns a footnote for markdown. @@ -202,9 +205,16 @@ func Footnote(reference, text string) string { } // Paragraph wraps the given text in a Markdown paragraph. -// Example: Paragraph("foo") => "foo\n" +// Preserves indentation for multiline content func Paragraph(content string) string { - return content + "\n\n" + lines := strings.Split(content, "\n") + // Trim first line + lines[0] = strings.TrimSpace(lines[0]) + // For subsequent lines, only trim right space but preserve left indentation + for i := 1; i < len(lines); i++ { + lines[i] = strings.TrimRight(lines[i], " \t") + } + return strings.Join(lines, "\n") } // CollapsibleSection creates a collapsible section for markdown using diff --git a/examples/gno.land/p/moul/md/md_test.gno b/examples/gno.land/p/moul/md/md_test.gno index 144ae58d918..7cd0b4f6805 100644 --- a/examples/gno.land/p/moul/md/md_test.gno +++ b/examples/gno.land/p/moul/md/md_test.gno @@ -3,86 +3,124 @@ package md import ( "testing" - "gno.land/p/moul/md" + "gno.land/p/demo/uassert" ) func TestHelpers(t *testing.T) { tests := []struct { name string - function func() string + fn func() string expected string }{ - {"Bold", func() string { return md.Bold("foo") }, "**foo**"}, - {"Italic", func() string { return md.Italic("foo") }, "*foo*"}, - {"Strikethrough", func() string { return md.Strikethrough("foo") }, "~~foo~~"}, - {"H1", func() string { return md.H1("foo") }, "# foo\n"}, - {"HorizontalRule", md.HorizontalRule, "---\n"}, - {"InlineCode", func() string { return md.InlineCode("foo") }, "`foo`"}, - {"CodeBlock", func() string { return md.CodeBlock("foo") }, "```\nfoo\n```"}, - {"LanguageCodeBlock", func() string { return md.LanguageCodeBlock("go", "foo") }, "```go\nfoo\n```"}, - {"Link", func() string { return md.Link("foo", "http://example.com") }, "[foo](http://example.com)"}, - {"Image", func() string { return md.Image("foo", "http://example.com") }, "![foo](http://example.com)"}, - {"InlineImageWithLink", func() string { return md.InlineImageWithLink("alt", "image-url", "link-url") }, "[![alt](image-url)](link-url)"}, - {"Footnote", func() string { return md.Footnote("foo", "bar") }, "[foo]: bar"}, - {"Paragraph", func() string { return md.Paragraph("foo") }, "foo\n\n"}, + // Basic formatting + {"Bold", func() string { return Bold("foo") }, "**foo**"}, + {"Bold with spaces", func() string { return Bold(" foo ") }, "**foo**"}, + {"Italic", func() string { return Italic("foo") }, "*foo*"}, + {"Italic with spaces", func() string { return Italic(" foo ") }, "*foo*"}, + {"Strikethrough", func() string { return Strikethrough("foo") }, "~~foo~~"}, + + // Headers + {"H1", func() string { return H1("foo") }, "# foo"}, + {"H2", func() string { return H2("foo") }, "## foo"}, + {"H3", func() string { return H3("foo") }, "### foo"}, + {"H4", func() string { return H4("foo") }, "#### foo"}, + {"H5", func() string { return H5("foo") }, "##### foo"}, + {"H6", func() string { return H6("foo") }, "###### foo"}, + + // Headers with spaces + {"H1 with spaces", func() string { return H1(" foo ") }, "# foo"}, + {"H2 with spaces", func() string { return H2(" foo ") }, "## foo"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := tt.function() - if result != tt.expected { - t.Errorf("%s() = %q, want %q", tt.name, result, tt.expected) - } + got := tt.fn() + uassert.Equal(t, tt.expected, got) }) } } func TestLists(t *testing.T) { - t.Run("BulletList", func(t *testing.T) { - items := []string{"foo", "bar"} - expected := "- foo\n- bar\n" - result := md.BulletList(items) - if result != expected { - t.Errorf("BulletList(%q) = %q, want %q", items, result, expected) - } - }) + tests := []struct { + name string + fn func() string + expected string + }{ + {"BulletItem", func() string { return BulletItem("foo") }, "- foo\n"}, + {"BulletItem with spaces", func() string { return BulletItem(" foo ") }, "- foo\n"}, + {"BulletItem multiline", func() string { return BulletItem(" foo \n bar ") }, "- foo\n bar\n"}, + {"BulletList", func() string { return BulletList([]string{"foo", "bar"}) }, "- foo\n- bar\n"}, + {"BulletList with spaces", func() string { return BulletList([]string{" foo ", " bar "}) }, "- foo\n- bar\n"}, + {"OrderedList", func() string { return OrderedList([]string{"foo", "bar"}) }, "1. foo\n2. bar\n"}, + {"OrderedList with spaces", func() string { return OrderedList([]string{" foo ", " bar "}) }, "1. foo\n2. bar\n"}, + {"TodoList", func() string { return TodoList([]string{"foo", "bar"}, []bool{true, false}) }, "- [x] foo\n- [ ] bar\n"}, + {"TodoList with spaces", func() string { return TodoList([]string{" foo ", " bar \n more "}, []bool{true, false}) }, "- [x] foo\n- [ ] bar\n more\n"}, + } - t.Run("OrderedList", func(t *testing.T) { - items := []string{"foo", "bar"} - expected := "1. foo\n2. bar\n" - result := md.OrderedList(items) - if result != expected { - t.Errorf("OrderedList(%q) = %q, want %q", items, result, expected) - } - }) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.fn() + uassert.Equal(t, tt.expected, got) + }) + } +} - t.Run("TodoList", func(t *testing.T) { - items := []string{"foo", "bar\nmore bar"} - done := []bool{true, false} - expected := "- [x] foo\n- [ ] bar\n more bar\n" - result := md.TodoList(items, done) - if result != expected { - t.Errorf("TodoList(%q, %q) = %q, want %q", items, done, result, expected) - } - }) +func TestFormatting(t *testing.T) { + tests := []struct { + name string + fn func() string + expected string + }{ + {"Blockquote", func() string { return Blockquote("foo") }, "> foo"}, + {"Blockquote with spaces", func() string { return Blockquote(" foo \n bar ") }, "> foo\n> bar"}, + {"Link", func() string { return Link("foo", "http://example.com") }, "[foo](http://example.com)"}, + {"Link with spaces", func() string { return Link(" foo ", "http://example.com") }, "[foo](http://example.com)"}, + {"Image", func() string { return Image("foo", "http://example.com") }, "![foo](http://example.com)"}, + {"Image with spaces", func() string { return Image(" foo ", "http://example.com") }, "![foo](http://example.com)"}, + {"Paragraph", func() string { return Paragraph("foo") }, "foo"}, + {"Paragraph with spaces", func() string { return Paragraph(" foo ") }, "foo"}, + {"Paragraph multiline", func() string { return Paragraph(" foo \n bar ") }, "foo\n bar"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.fn() + uassert.Equal(t, tt.expected, got) + }) + } } func TestNested(t *testing.T) { - t.Run("Nested Single Level", func(t *testing.T) { - content := "- foo\n- bar" - expected := " - foo\n - bar" - result := md.Nested(content, " ") - if result != expected { - t.Errorf("Nested(%q) = %q, want %q", content, result, expected) - } - }) + tests := []struct { + name string + content string + prefix string + expected string + }{ + { + name: "basic nesting", + content: "- foo\n- bar", + prefix: " ", + expected: " - foo\n - bar", + }, + { + name: "preserve spaces", + content: " - foo \n - bar ", + prefix: " ", + expected: " - foo \n - bar ", + }, + { + name: "empty lines", + content: "- foo\n\n- bar", + prefix: " ", + expected: " - foo\n\n - bar", + }, + } - t.Run("Nested Double Level", func(t *testing.T) { - content := " - foo\n - bar" - expected := " - foo\n - bar" - result := md.Nested(content, " ") - if result != expected { - t.Errorf("Nested(%q) = %q, want %q", content, result, expected) - } - }) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Nested(tt.content, tt.prefix) + uassert.Equal(t, tt.expected, got) + }) + } } diff --git a/examples/gno.land/p/moul/template/gno.mod b/examples/gno.land/p/moul/template/gno.mod new file mode 100644 index 00000000000..b7c5f2d40dc --- /dev/null +++ b/examples/gno.land/p/moul/template/gno.mod @@ -0,0 +1 @@ +module gno.land/p/moul/template diff --git a/examples/gno.land/p/moul/template/template.gno b/examples/gno.land/p/moul/template/template.gno new file mode 100644 index 00000000000..9ffc1d182f2 --- /dev/null +++ b/examples/gno.land/p/moul/template/template.gno @@ -0,0 +1,659 @@ +package template + +import ( + "errors" + "fmt" + "strings" + "unicode" + + "gno.land/p/moul/md" + "gno.land/p/moul/typeutil" +) + +// Add at the top of the file, after package declaration +var debug bool = false + +func log(args ...interface{}) { + if !debug { + return + } + // Convert args to strings and join them + var s string + for i, arg := range args { + if i > 0 { + s += " " + } + s += typeutil.ToString(arg) + } + println(s) +} + +// Context kinds +const ( + kindNone = iota + kindRange + kindIf + kindElse +) + +// Context management +type context struct { + parent *context + currentRange []interface{} + rangeMap map[string]interface{} + rangeIndex int + kind int +} + +// Renderer is the main template processor +type Renderer struct { + helpers map[string]Helper + data map[string]interface{} + context *context + lastContent string +} + +// NewRenderer creates a new template renderer with default helpers +func NewRenderer() *Renderer { + r := &Renderer{ + helpers: make(map[string]Helper), + data: make(map[string]interface{}), + } + + // Register markdown helpers + r.helpers["H1"] = SingleArgHelper{fn: md.H1} + r.helpers["H2"] = SingleArgHelper{fn: md.H2} + r.helpers["H3"] = SingleArgHelper{fn: md.H3} + r.helpers["H4"] = SingleArgHelper{fn: md.H4} + r.helpers["H5"] = SingleArgHelper{fn: md.H5} + r.helpers["H6"] = SingleArgHelper{fn: md.H6} + r.helpers["Bold"] = SingleArgHelper{fn: md.Bold} + r.helpers["Italic"] = SingleArgHelper{fn: md.Italic} + r.helpers["Strikethrough"] = SingleArgHelper{fn: md.Strikethrough} + r.helpers["InlineCode"] = SingleArgHelper{fn: md.InlineCode} + r.helpers["BulletItem"] = SingleArgHelper{fn: md.BulletItem} + r.helpers["TodoItem"] = TwoArgStringBoolHelper{fn: md.TodoItem} + r.helpers["CodeBlock"] = SingleArgHelper{fn: md.CodeBlock} + r.helpers["LanguageCodeBlock"] = TwoArgHelper{fn: md.LanguageCodeBlock} + r.helpers["Link"] = TwoArgHelper{fn: md.Link} + r.helpers["Image"] = TwoArgHelper{fn: md.Image} + r.helpers["InlineImageWithLink"] = ThreeArgHelper{fn: md.InlineImageWithLink} + r.helpers["Blockquote"] = SingleArgHelper{fn: md.Blockquote} + r.helpers["HorizontalRule"] = SingleArgHelper{fn: func(string) string { return md.HorizontalRule() }} + r.helpers["Footnote"] = TwoArgHelper{fn: md.Footnote} + r.helpers["EscapeText"] = SingleArgHelper{fn: md.EscapeText} + r.helpers["Paragraph"] = SingleArgHelper{fn: md.Paragraph} + r.helpers["CollapsibleSection"] = TwoArgHelper{fn: md.CollapsibleSection} + + // Register other helpers (in separate category) + r.helpers["concat"] = VarArgHelper{fn: concatHelper} + + return r +} + +func (r *Renderer) processPlaceholder(content string) (string, error) { + parts := splitParts(content) + if len(parts) == 0 { + return "", nil + } + + // First check if it's just a variable reference (starts with dot) + if strings.HasPrefix(parts[0], ".") { + val, ok := r.getVar(parts[0]) + if ok { + return typeutil.ToString(val), nil + } + // Return empty string if variable not found instead of the variable name + return "", nil + } + + switch parts[0] { + case "range": + log("=== Processing range placeholder ===") + log("Content:", content) + if len(parts) < 2 { + return "", errors.New("range requires a variable") + } + val, ok := r.getVar(parts[1]) + if !ok { + log("Variable not found:", parts[1]) + return "", nil + } + log("Variable value:", val) + + rangeContent := r.extractRangeContent(strings.Join(parts[1:], " ")) + log("Extracted range content:", rangeContent) + if rangeContent == "" { + return "", fmt.Errorf("could not find range content") + } + + var result strings.Builder + var rangeData []interface{} + + switch v := val.(type) { + case []interface{}: + rangeData = v + case []map[string]interface{}: + rangeData = make([]interface{}, len(v)) + for i, m := range v { + rangeData[i] = m + } + default: + if slice := typeutil.ToInterfaceSlice(v); slice != nil { + rangeData = slice + } else { + return "", fmt.Errorf("cannot range over %T", val) + } + } + + for i, item := range rangeData { + newContext := &context{ + parent: r.context, + currentRange: rangeData, + rangeIndex: i, + kind: kindRange, + rangeMap: nil, + } + + if m, ok := item.(map[string]interface{}); ok { + newContext.rangeMap = m + } + + subRenderer := &Renderer{ + helpers: r.helpers, + data: r.data, + context: newContext, + } + + processed := subRenderer.Render(rangeContent, nil) + result.WriteString(processed) + } + + log("Final range result:", result.String()) + return result.String(), nil + + case "end": + return "", nil + + case "if": + if len(parts) < 2 { + return "", fmt.Errorf("if requires a condition") + } + val, ok := r.getVar(parts[1]) + if !ok { + val = false + } + condition := typeutil.ToBool(val) + + ifContent := r.extractIfContent(strings.Join(parts[1:], " ")) + if ifContent == "" { + return "", nil + } + + if condition { + newContext := &context{ + parent: r.context, + kind: kindIf, + } + + subRenderer := &Renderer{ + helpers: r.helpers, + data: r.data, + context: newContext, + lastContent: ifContent, + } + return subRenderer.Render(ifContent, nil), nil + } + return "", nil + + default: + // Check for helpers + if helper, ok := r.helpers[parts[0]]; ok { + return helper.Execute(parts[1:], r.data), nil + } + + // If not a helper and not a special command, treat as plain text + return strings.Join(parts, " "), nil + } +} + +// splitParts splits a template expression into parts, respecting quoted strings +func splitParts(s string) []string { + var parts []string + var current strings.Builder + inQuote := false + + for i := 0; i < len(s); i++ { + switch s[i] { + case '"': + inQuote = !inQuote + current.WriteByte(s[i]) + case ' ': + if !inQuote { + if current.Len() > 0 { + parts = append(parts, current.String()) + current.Reset() + } + } else { + current.WriteByte(s[i]) + } + default: + current.WriteByte(s[i]) + } + } + + if current.Len() > 0 { + parts = append(parts, current.String()) + } + + return parts +} + +func trimMarkers(content string) (string, bool, bool) { + content = strings.TrimSpace(content) + trimLeft := false + trimRight := false + + if strings.HasPrefix(content, "-") { + trimLeft = true + content = strings.TrimPrefix(content, "-") + content = strings.TrimLeftFunc(content, unicode.IsSpace) + } + if strings.HasSuffix(content, "-") { + trimRight = true + content = strings.TrimSuffix(content, "-") + content = strings.TrimRightFunc(content, unicode.IsSpace) + } + return strings.TrimSpace(content), trimLeft, trimRight +} + +// Render processes the template with the given data +func (r *Renderer) Render(template string, data map[string]interface{}) string { + if data != nil { + r.data = data + } + + var result strings.Builder + lastPos := 0 + r.lastContent = template + + for i := 0; i < len(template); { + if i+1 < len(template) && template[i] == '{' && template[i+1] == '{' { + end := i + 2 + depth := 1 + for end < len(template)-1 { + if template[end] == '}' && template[end+1] == '}' { + depth-- + if depth == 0 { + break + } + } else if template[end] == '{' && template[end+1] == '{' { + depth++ + } + end++ + } + + if end < len(template)-1 { + content := template[i+2 : end] + content, trimLeft, trimRight := trimMarkers(content) + + if trimLeft { + result.WriteString(strings.TrimRightFunc(template[lastPos:i], unicode.IsSpace)) + } else { + result.WriteString(template[lastPos:i]) + } + + output, err := r.processPlaceholder(content) + if err != nil { + log("Error processing placeholder:", err) + return fmt.Sprintf("[error: %v]", err) + } + result.WriteString(output) + + i = end + 2 + lastPos = i + + if trimRight { + for lastPos < len(template) && unicode.IsSpace(rune(template[lastPos])) { + lastPos++ + i++ + } + } + continue + } + } + i++ + } + + if lastPos < len(template) { + result.WriteString(template[lastPos:]) + } + + return result.String() +} + +// Context management methods +func (r *Renderer) pushContext(data interface{}, kind int) { + nc := &context{ + parent: r.context, + kind: kind, + } + + switch v := data.(type) { + case []interface{}: + nc.currentRange = v + nc.rangeIndex = 0 + case map[string]interface{}: + nc.rangeMap = v + case bool: + nc.currentRange = []interface{}{v} + } + + r.context = nc +} + +func (r *Renderer) popContext() { + if r.context != nil { + r.context = r.context.parent + } +} + +// getVar retrieves a variable from the current context or data +func (r *Renderer) getVar(name string) (interface{}, bool) { + if r.context != nil && r.context.kind == kindRange { + if name == "." { + if r.context.currentRange != nil && r.context.rangeIndex < len(r.context.currentRange) { + return r.context.currentRange[r.context.rangeIndex], true + } + } else if strings.HasPrefix(name, ".") { + name = strings.TrimPrefix(name, ".") + // First check rangeMap if available + if r.context.rangeMap != nil { + if val, exists := r.context.rangeMap[name]; exists { + return val, true + } + } + // Then check current range item if it's a map + if r.context.currentRange != nil && r.context.rangeIndex < len(r.context.currentRange) { + if item, ok := r.context.currentRange[r.context.rangeIndex].(map[string]interface{}); ok { + if val, exists := item[name]; exists { + return val, true + } + } + } + } + } + + // If not found in context, look in data + return r.lookupVar(strings.TrimPrefix(name, ".")) +} + +// lookupVar looks up a variable in a map by name, supporting dot notation +func (r *Renderer) lookupVar(name string) (interface{}, bool) { + // Split the name into parts for nested lookups + parts := strings.Split(name, ".") + + current := r.data + for i, part := range parts { + if i == len(parts)-1 { + // Last part - return the value + val, ok := current[part] + return val, ok + } + + // Not the last part - must be a map + next, ok := current[part] + if !ok { + return nil, false + } + + // Convert to map for next iteration + nextMap, ok := next.(map[string]interface{}) + if !ok { + return nil, false + } + current = nextMap + } + + return nil, false +} + +// Helper interface definition +type Helper interface { + Execute(args []string, data map[string]interface{}) string +} + +// SingleArgHelper implementation +type SingleArgHelper struct { + fn func(string) string +} + +func (h SingleArgHelper) Execute(args []string, data map[string]interface{}) string { + if len(args) < 1 { + return "[error: missing argument]" + } + + // Join all args for quoted strings that might contain spaces + arg := strings.Join(args, " ") + + // Handle quoted strings properly + if strings.HasPrefix(arg, "\"") && strings.HasSuffix(arg, "\"") { + // Remove outer quotes and keep inner quotes as-is + arg = arg[1 : len(arg)-1] + } + + return h.fn(arg) +} + +// VarArgHelper implementation +type VarArgHelper struct { + fn func([]string) string +} + +func (h VarArgHelper) Execute(args []string, data map[string]interface{}) string { + processedArgs := make([]string, len(args)) + for i, arg := range args { + arg = strings.TrimSpace(arg) + if strings.HasPrefix(arg, ".") { + if val, ok := data[strings.TrimPrefix(arg, ".")]; ok { + processedArgs[i] = typeutil.ToString(val) + continue + } + } else if strings.HasPrefix(arg, "\"") && strings.HasSuffix(arg, "\"") { + processedArgs[i] = arg[1 : len(arg)-1] + continue + } + processedArgs[i] = arg + } + return h.fn(processedArgs) +} + +// TwoArgHelper implementation +type TwoArgHelper struct { + fn func(string, string) string +} + +func (h TwoArgHelper) Execute(args []string, data map[string]interface{}) string { + if len(args) < 2 { + return "[error: requires two arguments]" + } + + // Process first argument + arg1 := args[0] + if strings.HasPrefix(arg1, "\"") && strings.HasSuffix(arg1, "\"") { + arg1 = arg1[1 : len(arg1)-1] + } + + // Process second argument + arg2 := args[1] + if strings.HasPrefix(arg2, "\"") && strings.HasSuffix(arg2, "\"") { + arg2 = arg2[1 : len(arg2)-1] + } + + return h.fn(arg1, arg2) +} + +// TwoArgStringBoolHelper implementation +type TwoArgStringBoolHelper struct { + fn func(string, bool) string +} + +func (h TwoArgStringBoolHelper) Execute(args []string, data map[string]interface{}) string { + if len(args) < 2 { + return "[error: requires two arguments]" + } + + // Process first argument as a string + arg1 := args[0] + if strings.HasPrefix(arg1, "\"") && strings.HasSuffix(arg1, "\"") { + arg1 = arg1[1 : len(arg1)-1] + } + + // Process second argument as a boolean + arg2 := strings.ToLower(args[1]) == "true" + + return h.fn(arg1, arg2) +} + +// ThreeArgHelper implementation +type ThreeArgHelper struct { + fn func(string, string, string) string +} + +func (h ThreeArgHelper) Execute(args []string, data map[string]interface{}) string { + if len(args) < 3 { + return "[error: requires three arguments]" + } + + // Process first argument + arg1 := args[0] + if strings.HasPrefix(arg1, "\"") && strings.HasSuffix(arg1, "\"") { + arg1 = arg1[1 : len(arg1)-1] + } + + // Process second argument + arg2 := args[1] + if strings.HasPrefix(arg2, "\"") && strings.HasSuffix(arg2, "\"") { + arg2 = arg2[1 : len(arg2)-1] + } + + // Process third argument + arg3 := args[2] + if strings.HasPrefix(arg3, "\"") && strings.HasSuffix(arg3, "\"") { + arg3 = arg3[1 : len(arg3)-1] + } + + return h.fn(arg1, arg2, arg3) +} + +// Helper function for string concatenation +func concatHelper(args []string) string { + return strings.Join(args, "") +} + +// New helper function to extract range content +func (r *Renderer) extractRangeContent(rangeExpr string) string { + log("=== extractRangeContent Debug ===") + log("Range Expression:", rangeExpr) + log("Last Content:", r.lastContent) + + // Look for the range start marker + marker := "{{ range " + strings.TrimSpace(rangeExpr) + " }}" + start := strings.Index(r.lastContent, marker) + if start < 0 { + // Try without spaces as fallback + marker = "{{range " + strings.TrimSpace(rangeExpr) + "}}" + start = strings.Index(r.lastContent, marker) + if start < 0 { + log("Could not find range start marker") + return "" + } + } + log("Found range start marker at position:", start) + log("Marker:", marker) + + contentStart := start + len(marker) + depth := 1 + endPos := -1 + + log("Starting content scan from position:", contentStart) + for i := contentStart; i < len(r.lastContent)-5; i++ { + if i+2 <= len(r.lastContent) && r.lastContent[i:i+2] == "{{" { + // Skip any whitespace after {{ + j := i + 2 + for j < len(r.lastContent) && unicode.IsSpace(rune(r.lastContent[j])) { + j++ + } + + // Check for range or end keywords + rest := r.lastContent[j:] + if strings.HasPrefix(rest, "range") { + log("Found nested range at position:", i) + depth++ + log("Depth increased to:", depth) + } else if strings.HasPrefix(rest, "end") { + log("Found end tag at position:", i) + depth-- + log("Depth decreased to:", depth) + if depth == 0 { + endPos = i + log("Found matching end tag at position:", endPos) + break + } + } + } + } + + if endPos == -1 { + log("No matching end tag found") + return "" + } + + content := r.lastContent[contentStart:endPos] + log("Extracted content:", content) + return content +} + +// Add a similar method for if blocks +func (r *Renderer) extractIfContent(ifExpr string) string { + marker := "{{ if " + strings.TrimSpace(ifExpr) + " }}" + start := strings.Index(r.lastContent, marker) + if start < 0 { + marker = "{{if " + strings.TrimSpace(ifExpr) + "}}" + start = strings.Index(r.lastContent, marker) + if start < 0 { + return "" + } + } + + contentStart := start + len(marker) + depth := 1 + endPos := -1 + elsePos := -1 + + for i := contentStart; i < len(r.lastContent)-5; i++ { + if i+2 <= len(r.lastContent) && r.lastContent[i:i+2] == "{{" { + rest := r.lastContent[i+2:] + if strings.HasPrefix(rest, " if ") || strings.HasPrefix(rest, "if ") { + depth++ + } else if strings.HasPrefix(rest, " end ") || strings.HasPrefix(rest, "end ") { + depth-- + if depth == 0 { + endPos = i + break + } + } else if depth == 1 && (strings.HasPrefix(rest, " else ") || strings.HasPrefix(rest, "else ")) { + elsePos = i + } + } + } + + if endPos == -1 { + return "" + } + + if elsePos != -1 { + return r.lastContent[contentStart:elsePos] + } + return r.lastContent[contentStart:endPos] +} diff --git a/examples/gno.land/p/moul/template/template_test.gno b/examples/gno.land/p/moul/template/template_test.gno new file mode 100644 index 00000000000..d6240d1a59c --- /dev/null +++ b/examples/gno.land/p/moul/template/template_test.gno @@ -0,0 +1,289 @@ +package template + +import ( + "testing" +) + +func TestTrimMarkers(t *testing.T) { + tests := []struct { + name string + template string + data map[string]interface{} + expected string + }{ + { + name: "no_trim", + template: "{{23}} < {{45}}", + expected: "23 < 45", + }, + { + name: "trim_both", + template: "{{23 -}} < {{- 45}}", + expected: "23<45", + }, + { + name: "trim_left_only", + template: "{{23}} < {{- 45}}", + expected: "23 <45", + }, + { + name: "trim_right_only", + template: "{{23 -}} < {{45}}", + expected: "23< 45", + }, + { + name: "multiple_spaces", + template: "{{23 -}} < {{- 45}}", + expected: "23<45", + }, + { + name: "newlines", + template: "{{23 -}}\n<\n{{- 45}}", + expected: "23<45", + }, + { + name: "helper_with_trim", + template: "{{Bold \"hello\" -}} {{- Bold \"world\"}}", + expected: "**hello****world**", + }, + { + name: "variable_with_trim", + template: "{{ .First -}} {{- .Second}}", + data: map[string]interface{}{ + "First": "Hello", + "Second": "World", + }, + expected: "HelloWorld", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := NewRenderer() + got := r.Render(tt.template, tt.data) + if got != tt.expected { + t.Errorf("\nTemplate: %s\nExpected: %q\nGot: %q", tt.template, tt.expected, got) + } + }) + } +} + +func TestNestedPlaceholders(t *testing.T) { + template := `# Welcome, {{ .Name }}! +[Your Profile]({{ .ProfileURL }})` + + data := map[string]interface{}{ + "Name": "Alice", + "ProfileURL": "https://example.com/profile/Alice", + } + + expected := `# Welcome, Alice! +[Your Profile](https://example.com/profile/Alice)` + + r := NewRenderer() + got := r.Render(template, data) + if got != expected { + t.Errorf("\nExpected:\n%s\nGot:\n%s", expected, got) + } +} + +func TestHelpers(t *testing.T) { + tests := []struct { + name string + template string + data map[string]interface{} + expected string + }{ + { + name: "bold_helper", + template: `{{ Bold "hello" }}`, + expected: "**hello**", + }, + { + name: "multiple_helpers", + template: `{{ H1 "Section" }} +{{ Bold "Important" }} +{{ BulletItem "First" }} +{{ BulletItem "Second" }}`, + expected: "# Section\n**Important**\n- First\n\n- Second\n", + }, + { + name: "helper_with_quotes", + template: `{{ Bold "\"quoted text\"" }}`, + expected: `**\"quoted text\"**`, + }, + { + name: "helper_with_spaces", + template: `{{ Bold " spaced " }}`, + expected: "**spaced**", + }, + { + name: "concat_two_strings", + template: `{{ concat "Hello" " World" }}`, + expected: "Hello World", + }, + { + name: "concat_multiple_strings", + template: `{{ concat "Hello" " " "wonderful" " " "World" "!" }}`, + expected: "Hello wonderful World!", + }, + { + name: "concat_with_vars", + template: `{{ concat .Greeting " " .Name "!" }}`, + data: map[string]interface{}{ + "Greeting": "Hello", + "Name": "Alice", + }, + expected: "Hello Alice!", + }, + { + name: "concat_empty", + template: `{{ concat }}`, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := NewRenderer() + got := r.Render(tt.template, tt.data) + if got != tt.expected { + t.Errorf("\nTemplate: %s\nExpected: %q\nGot: %q", tt.template, tt.expected, got) + } + }) + } +} + +func TestHelperErrors(t *testing.T) { + tests := []struct { + name string + template string + }{ + { + name: "unknown_helper", + template: `{{ Unknown "test" }}`, + }, + { + name: "missing_argument", + template: `{{ Bold }}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := NewRenderer() + got := r.Render(tt.template, nil) + // Should not panic and should return some result + if got == "" { + t.Error("Expected non-empty result even with errors") + } + }) + } +} + +func TestControlFlow(t *testing.T) { + tests := []struct { + name string + template string + data map[string]interface{} + expected string + }{ + { + name: "simple_range", + template: `{{ range .Items }}{{ . }}{{ end }}`, + data: map[string]interface{}{ + "Items": []string{"a", "b", "c"}, + }, + expected: "abc", + }, + { + name: "range_with_index", + template: `{{ range .Items }}[{{ . }}]{{ end }}`, + data: map[string]interface{}{ + "Items": []int{1, 2, 3}, + }, + expected: "[1][2][3]", + }, + { + name: "range_with_field", + template: "{{ range .Objects }}[{{ .Name }}]{{ end }}", + data: map[string]interface{}{ + "Objects": []map[string]interface{}{ + {"Name": "obj1"}, + {"Name": "obj2"}, + }, + }, + expected: "[obj1][obj2]", + }, + { + name: "range_with_helpers", + template: `{{ range .Items }}{{ Bold . }}{{ end }}`, + data: map[string]interface{}{ + "Items": []string{"a", "b", "c"}, + }, + expected: "**a****b****c**", + }, + { + name: "Nested Range", + template: "{{ range .Outer }}{{ range .Inner }}{{ . }}{{ end }}{{ end }}", + data: map[string]interface{}{ + "Outer": []interface{}{ + map[string]interface{}{"Inner": []interface{}{"a", "b"}}, + map[string]interface{}{"Inner": []interface{}{"c", "d"}}, + }, + }, + expected: "abcd", + }, + { + name: "Range in If", + template: "{{ if .Show }}{{ range .Items }}{{ . }}{{ end }}{{ end }}", + data: map[string]interface{}{ + "Show": true, + "Items": []interface{}{"1", "2", "3"}, + }, + expected: "123", + }, + { + name: "If in Range", + template: "{{ range .Items }}{{ if .Show }}{{ .Value }}{{ end }}{{ end }}", + data: map[string]interface{}{ + "Items": []interface{}{ + map[string]interface{}{"Show": true, "Value": "x"}, + map[string]interface{}{"Show": false, "Value": "y"}, + map[string]interface{}{"Show": true, "Value": "z"}, + }, + }, + expected: "xz", + }, + { + name: "If in If", + template: "{{ if .Outer }}{{ if .Inner }}Yes{{ else }}No{{ end }}{{ else }}No{{ end }}", + data: map[string]interface{}{ + "Outer": true, + "Inner": true, + }, + expected: "Yes", + }, + { + name: "If in If in If", + template: "{{ if .First }}{{ if .Second }}{{ if .Third }}All True{{ else }}Third False{{ end }}{{ else }}Second False{{ end }}{{ else }}First False{{ end }}", + data: map[string]interface{}{ + "First": true, + "Second": true, + "Third": false, + }, + expected: "Third False", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := NewRenderer() + got := r.Render(tt.template, tt.data) + if got != tt.expected { + t.Errorf("\nTemplate: %s\nData: %v\nExpected: %q\nGot: %q", tt.template, tt.data, tt.expected, got) + } + }) + } +} + diff --git a/examples/gno.land/p/moul/template/z1_filetest.gno b/examples/gno.land/p/moul/template/z1_filetest.gno new file mode 100644 index 00000000000..ca3dfb7f536 --- /dev/null +++ b/examples/gno.land/p/moul/template/z1_filetest.gno @@ -0,0 +1,130 @@ +package main + +import "gno.land/p/moul/template" + +func main() { + markdownTemplate := `{{ H1 "Welcome to {{ .Name }}'s Page!" }} + +{{ Bold "Skills" -}} +{{- range .Skills }} +{{ BulletItem . -}} +{{- end }} + +{{ if .ShowProjects -}} +{{ H2 "Projects" }} +{{- range .Projects }} +{{ H3 .Name }} +{{ Paragraph .Description }} +{{- if .HasDemo }} +{{ Link "View Demo" .DemoURL }} +{{- end }} +{{- end }} +{{- end }} + +{{ H2 "Contact Information" }} +{{- range .Contacts }} +{{ BulletItem (printf "%s: %s" .Platform .Value) -}} +{{- end }} + +{{ H2 "Recent Posts" }} +{{- if .HasPosts }} +{{- range .RecentPosts }} +{{ H3 .Title }} +{{ Italic .Date }} +{{ Paragraph .Summary }} +{{- if .HasComments }} +{{ Blockquote (printf "Latest comment: %s" (index .Comments 0)) }} +{{- end }} +{{ HorizontalRule -}} +{{- end }} +{{- end }}` + + data := map[string]interface{}{ + "Name": "Alice", + "Skills": []interface{}{ + "Go Programming", + "Smart Contracts", + "Blockchain Development", + "System Architecture", + }, + "ShowProjects": true, + "Projects": []interface{}{ + map[string]interface{}{ + "Name": "Gno Templates", + "Description": "A template engine for Gno", + "HasDemo": true, + "DemoURL": "https://example.com/demo", + }, + map[string]interface{}{ + "Name": "Blockchain Explorer", + "Description": "A tool for exploring the Gno chain", + "HasDemo": false, + }, + }, + "Contacts": []interface{}{ + map[string]interface{}{ + "Platform": "Email", + "Value": "alice@gno.land", + }, + map[string]interface{}{ + "Platform": "GitHub", + "Value": "@alice", + }, + }, + "HasPosts": true, + "RecentPosts": []interface{}{ + map[string]interface{}{ + "Title": "Getting Started with Gno", + "Date": "2024-03-20", + "Summary": "A beginner's guide to Gno development", + "HasComments": true, + "Comments": []interface{}{"Great article!", "Very helpful"}, + }, + map[string]interface{}{ + "Title": "Advanced Template Techniques", + "Date": "2024-03-19", + "Summary": "Deep dive into Gno templates", + "HasComments": false, + }, + }, + } + + renderer := template.NewRenderer() + output := renderer.Render(markdownTemplate, data) + println(output) +} + +// Output: +// # Welcome to {{ .Name +// 's Page!" }} +// +// **Skills**{{ invalid range helper }} +// - . +// +// +// +// ## Projects +// {{ invalid range helper }} +// ### Alice +// +// .Description +// +// [View Demo]( .DemoURL) +// +// ## Contact Information +// {{ invalid range helper }} +// - (printf "%s: %s" .Platform .Value) +// +// +// +// ## Recent Posts +// {{ invalid range helper }} +// ### .Title +// +// *.Date* +// .Summary +// +// > (printf "Latest comment: %s" (index .Comments 0)) +// +// --- +// diff --git a/examples/gno.land/p/moul/typeutil/gno.mod b/examples/gno.land/p/moul/typeutil/gno.mod new file mode 100644 index 00000000000..4f9c432456b --- /dev/null +++ b/examples/gno.land/p/moul/typeutil/gno.mod @@ -0,0 +1 @@ +module gno.land/p/moul/typeutil diff --git a/examples/gno.land/p/moul/typeutil/typeutil.gno b/examples/gno.land/p/moul/typeutil/typeutil.gno new file mode 100644 index 00000000000..1fa79b94549 --- /dev/null +++ b/examples/gno.land/p/moul/typeutil/typeutil.gno @@ -0,0 +1,715 @@ +// Package typeutil provides utility functions for converting between different types +// and checking their states. It aims to provide consistent behavior across different +// types while remaining lightweight and dependency-free. +package typeutil + +import ( + "errors" + "sort" + "std" + "strconv" + "strings" + "time" +) + +// stringer is the interface that wraps the String method. +type stringer interface { + String() string +} + +// ToString converts any value to its string representation. +// It supports a wide range of Go types including: +// - Basic: string, bool +// - Numbers: int, int8-64, uint, uint8-64, float32, float64 +// - Special: time.Time, std.Address, []byte +// - Slices: []T for most basic types +// - Maps: map[string]string, map[string]interface{} +// - Interface: types implementing String() string +// +// Example usage: +// +// str := typeutil.ToString(42) // "42" +// str = typeutil.ToString([]int{1, 2}) // "[1 2]" +// str = typeutil.ToString(map[string]string{ // "map[a:1 b:2]" +// "a": "1", +// "b": "2", +// }) +func ToString(val interface{}) string { + if val == nil { + return "" + } + + // First check if value implements Stringer interface + if s, ok := val.(interface{ String() string }); ok { + return s.String() + } + + switch v := val.(type) { + // Pointer types - dereference and recurse + case *string: + if v == nil { + return "" + } + return *v + case *int: + if v == nil { + return "" + } + return strconv.Itoa(*v) + case *bool: + if v == nil { + return "" + } + return strconv.FormatBool(*v) + case *time.Time: + if v == nil { + return "" + } + return v.String() + case *std.Address: + if v == nil { + return "" + } + return string(*v) + + // String types + case string: + return v + case stringer: + return v.String() + + // Special types + case time.Time: + return v.String() + case std.Address: + return string(v) + case []byte: + return string(v) + case struct{}: + return "{}" + + // Integer types + case int: + return strconv.Itoa(v) + case int8: + return strconv.FormatInt(int64(v), 10) + case int16: + return strconv.FormatInt(int64(v), 10) + case int32: + return strconv.FormatInt(int64(v), 10) + case int64: + return strconv.FormatInt(v, 10) + case uint: + return strconv.FormatUint(uint64(v), 10) + case uint8: + return strconv.FormatUint(uint64(v), 10) + case uint16: + return strconv.FormatUint(uint64(v), 10) + case uint32: + return strconv.FormatUint(uint64(v), 10) + case uint64: + return strconv.FormatUint(v, 10) + + // Float types + case float32: + return strconv.FormatFloat(float64(v), 'f', -1, 32) + case float64: + return strconv.FormatFloat(v, 'f', -1, 64) + + // Boolean + case bool: + if v { + return "true" + } + return "false" + + // Slice types + case []string: + return join(v) + case []int: + return join(v) + case []int32: + return join(v) + case []int64: + return join(v) + case []float32: + return join(v) + case []float64: + return join(v) + case []interface{}: + return join(v) + case []time.Time: + return joinTimes(v) + case []stringer: + return join(v) + case []std.Address: + return joinAddresses(v) + case [][]byte: + return joinBytes(v) + + // Map types with various key types + case map[interface{}]interface{}, map[string]interface{}, map[string]string, map[string]int: + var b strings.Builder + b.WriteString("map[") + first := true + + switch m := v.(type) { + case map[interface{}]interface{}: + // Convert all keys to strings for consistent ordering + keys := make([]string, 0) + keyMap := make(map[string]interface{}) + + for k := range m { + keyStr := ToString(k) + keys = append(keys, keyStr) + keyMap[keyStr] = k + } + sort.Strings(keys) + + for _, keyStr := range keys { + if !first { + b.WriteString(" ") + } + origKey := keyMap[keyStr] + b.WriteString(keyStr) + b.WriteString(":") + b.WriteString(ToString(m[origKey])) + first = false + } + + case map[string]interface{}: + keys := make([]string, 0) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, k := range keys { + if !first { + b.WriteString(" ") + } + b.WriteString(k) + b.WriteString(":") + b.WriteString(ToString(m[k])) + first = false + } + + case map[string]string: + keys := make([]string, 0) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, k := range keys { + if !first { + b.WriteString(" ") + } + b.WriteString(k) + b.WriteString(":") + b.WriteString(m[k]) + first = false + } + + case map[string]int: + keys := make([]string, 0) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, k := range keys { + if !first { + b.WriteString(" ") + } + b.WriteString(k) + b.WriteString(":") + b.WriteString(strconv.Itoa(m[k])) + first = false + } + } + b.WriteString("]") + return b.String() + + // Default + default: + return "" + } +} + +func join(slice interface{}) string { + if IsZero(slice) { + return "[]" + } + + items := ToInterfaceSlice(slice) + if items == nil { + return "[]" + } + + var b strings.Builder + b.WriteString("[") + for i, item := range items { + if i > 0 { + b.WriteString(" ") + } + b.WriteString(ToString(item)) + } + b.WriteString("]") + return b.String() +} + +func joinTimes(slice []time.Time) string { + if len(slice) == 0 { + return "[]" + } + var b strings.Builder + b.WriteString("[") + for i, t := range slice { + if i > 0 { + b.WriteString(" ") + } + b.WriteString(t.String()) + } + b.WriteString("]") + return b.String() +} + +func joinAddresses(slice []std.Address) string { + if len(slice) == 0 { + return "[]" + } + var b strings.Builder + b.WriteString("[") + for i, addr := range slice { + if i > 0 { + b.WriteString(" ") + } + b.WriteString(string(addr)) + } + b.WriteString("]") + return b.String() +} + +func joinBytes(slice [][]byte) string { + if len(slice) == 0 { + return "[]" + } + var b strings.Builder + b.WriteString("[") + for i, bytes := range slice { + if i > 0 { + b.WriteString(" ") + } + b.WriteString(string(bytes)) + } + b.WriteString("]") + return b.String() +} + +// ToBool converts any value to a boolean based on common programming conventions. +// For example: +// - Numbers: 0 is false, any other number is true +// - Strings: "", "0", "false", "f", "no", "n", "off" are false, others are true +// - Slices/Maps: empty is false, non-empty is true +// - nil: always false +// - bool: direct value +func ToBool(val interface{}) bool { + if IsZero(val) { + return false + } + + // Handle special string cases + if str, ok := val.(string); ok { + str = strings.ToLower(strings.TrimSpace(str)) + return str != "" && str != "0" && str != "false" && str != "f" && str != "no" && str != "n" && str != "off" + } + + return true +} + +// IsZero returns true if the value represents a "zero" or "empty" state for its type. +// For example: +// - Numbers: 0 +// - Strings: "" +// - Slices/Maps: empty +// - nil: true +// - bool: false +// - time.Time: IsZero() +// - std.Address: empty string +func IsZero(val interface{}) bool { + if val == nil { + return true + } + + switch v := val.(type) { + // Pointer types - nil pointer is zero, otherwise check pointed value + case *bool: + return v == nil || !*v + case *string: + return v == nil || *v == "" + case *int: + return v == nil || *v == 0 + case *time.Time: + return v == nil || v.IsZero() + case *std.Address: + return v == nil || string(*v) == "" + + // Bool + case bool: + return !v + + // String types + case string: + return v == "" + case stringer: + return v.String() == "" + + // Integer types + case int: + return v == 0 + case int8: + return v == 0 + case int16: + return v == 0 + case int32: + return v == 0 + case int64: + return v == 0 + case uint: + return v == 0 + case uint8: + return v == 0 + case uint16: + return v == 0 + case uint32: + return v == 0 + case uint64: + return v == 0 + + // Float types + case float32: + return v == 0 + case float64: + return v == 0 + + // Special types + case []byte: + return len(v) == 0 + case time.Time: + return v.IsZero() + case std.Address: + return string(v) == "" + + // Slices (check if empty) + case []string: + return len(v) == 0 + case []int: + return len(v) == 0 + case []int32: + return len(v) == 0 + case []int64: + return len(v) == 0 + case []float32: + return len(v) == 0 + case []float64: + return len(v) == 0 + case []interface{}: + return len(v) == 0 + case []time.Time: + return len(v) == 0 + case []std.Address: + return len(v) == 0 + case [][]byte: + return len(v) == 0 + case []stringer: + return len(v) == 0 + + // Maps (check if empty) + case map[string]string: + return len(v) == 0 + case map[string]interface{}: + return len(v) == 0 + + default: + return false // non-nil unknown types are considered non-zero + } +} + +// ToInterfaceSlice converts various slice types to []interface{} +func ToInterfaceSlice(val interface{}) []interface{} { + switch v := val.(type) { + case []interface{}: + return v + case []string: + result := make([]interface{}, len(v)) + for i, s := range v { + result[i] = s + } + return result + case []int: + result := make([]interface{}, len(v)) + for i, n := range v { + result[i] = n + } + return result + case []int32: + result := make([]interface{}, len(v)) + for i, n := range v { + result[i] = n + } + return result + case []int64: + result := make([]interface{}, len(v)) + for i, n := range v { + result[i] = n + } + return result + case []float32: + result := make([]interface{}, len(v)) + for i, n := range v { + result[i] = n + } + return result + case []float64: + result := make([]interface{}, len(v)) + for i, n := range v { + result[i] = n + } + return result + case []bool: + result := make([]interface{}, len(v)) + for i, b := range v { + result[i] = b + } + return result + default: + return nil + } +} + +// ToMapStringInterface converts a map with string keys and any value type to map[string]interface{} +func ToMapStringInterface(m interface{}) (map[string]interface{}, error) { + result := make(map[string]interface{}) + + switch v := m.(type) { + case map[string]interface{}: + return v, nil + case map[string]string: + for k, val := range v { + result[k] = val + } + case map[string]int: + for k, val := range v { + result[k] = val + } + case map[string]int64: + for k, val := range v { + result[k] = val + } + case map[string]float64: + for k, val := range v { + result[k] = val + } + case map[string]bool: + for k, val := range v { + result[k] = val + } + case map[string][]string: + for k, val := range v { + result[k] = ToInterfaceSlice(val) + } + case map[string][]int: + for k, val := range v { + result[k] = ToInterfaceSlice(val) + } + case map[string][]interface{}: + for k, val := range v { + result[k] = val + } + case map[string]map[string]interface{}: + for k, val := range v { + result[k] = val + } + case map[string]map[string]string: + for k, val := range v { + if converted, err := ToMapStringInterface(val); err == nil { + result[k] = converted + } else { + return nil, errors.New("failed to convert nested map at key: " + k) + } + } + default: + return nil, errors.New("unsupported map type: " + ToString(m)) + } + + return result, nil +} + +// ToMapIntInterface converts a map with int keys and any value type to map[int]interface{} +func ToMapIntInterface(m interface{}) (map[int]interface{}, error) { + result := make(map[int]interface{}) + + switch v := m.(type) { + case map[int]interface{}: + return v, nil + case map[int]string: + for k, val := range v { + result[k] = val + } + case map[int]int: + for k, val := range v { + result[k] = val + } + case map[int]int64: + for k, val := range v { + result[k] = val + } + case map[int]float64: + for k, val := range v { + result[k] = val + } + case map[int]bool: + for k, val := range v { + result[k] = val + } + case map[int][]string: + for k, val := range v { + result[k] = ToInterfaceSlice(val) + } + case map[int][]int: + for k, val := range v { + result[k] = ToInterfaceSlice(val) + } + case map[int][]interface{}: + for k, val := range v { + result[k] = val + } + case map[int]map[string]interface{}: + for k, val := range v { + result[k] = val + } + case map[int]map[int]interface{}: + for k, val := range v { + result[k] = val + } + default: + return nil, errors.New("unsupported map type: " + ToString(m)) + } + + return result, nil +} + +// ToStringSlice converts various slice types to []string +func ToStringSlice(val interface{}) []string { + switch v := val.(type) { + case []string: + return v + case []interface{}: + result := make([]string, len(v)) + for i, item := range v { + result[i] = ToString(item) + } + return result + case []int: + result := make([]string, len(v)) + for i, n := range v { + result[i] = strconv.Itoa(n) + } + return result + case []int32: + result := make([]string, len(v)) + for i, n := range v { + result[i] = strconv.FormatInt(int64(n), 10) + } + return result + case []int64: + result := make([]string, len(v)) + for i, n := range v { + result[i] = strconv.FormatInt(n, 10) + } + return result + case []float32: + result := make([]string, len(v)) + for i, n := range v { + result[i] = strconv.FormatFloat(float64(n), 'f', -1, 32) + } + return result + case []float64: + result := make([]string, len(v)) + for i, n := range v { + result[i] = strconv.FormatFloat(n, 'f', -1, 64) + } + return result + case []bool: + result := make([]string, len(v)) + for i, b := range v { + result[i] = strconv.FormatBool(b) + } + return result + case []time.Time: + result := make([]string, len(v)) + for i, t := range v { + result[i] = t.String() + } + return result + case []std.Address: + result := make([]string, len(v)) + for i, addr := range v { + result[i] = string(addr) + } + return result + case [][]byte: + result := make([]string, len(v)) + for i, b := range v { + result[i] = string(b) + } + return result + case []stringer: + result := make([]string, len(v)) + for i, s := range v { + result[i] = s.String() + } + return result + case []uint: + result := make([]string, len(v)) + for i, n := range v { + result[i] = strconv.FormatUint(uint64(n), 10) + } + return result + case []uint8: + result := make([]string, len(v)) + for i, n := range v { + result[i] = strconv.FormatUint(uint64(n), 10) + } + return result + case []uint16: + result := make([]string, len(v)) + for i, n := range v { + result[i] = strconv.FormatUint(uint64(n), 10) + } + return result + case []uint32: + result := make([]string, len(v)) + for i, n := range v { + result[i] = strconv.FormatUint(uint64(n), 10) + } + return result + case []uint64: + result := make([]string, len(v)) + for i, n := range v { + result[i] = strconv.FormatUint(n, 10) + } + return result + default: + // Try to convert using reflection if it's a slice + if slice := ToInterfaceSlice(val); slice != nil { + result := make([]string, len(slice)) + for i, item := range slice { + result[i] = ToString(item) + } + return result + } + return nil + } +} diff --git a/examples/gno.land/p/moul/typeutil/typeutil_test.gno b/examples/gno.land/p/moul/typeutil/typeutil_test.gno new file mode 100644 index 00000000000..543ea1deec4 --- /dev/null +++ b/examples/gno.land/p/moul/typeutil/typeutil_test.gno @@ -0,0 +1,1075 @@ +package typeutil + +import ( + "std" + "strings" + "testing" + "time" +) + +type testStringer struct { + value string +} + +func (t testStringer) String() string { + return "test:" + t.value +} + +func TestToString(t *testing.T) { + // setup test data + str := "hello" + num := 42 + b := true + now := time.Now() + addr := std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") + stringer := testStringer{value: "hello"} + + type testCase struct { + name string + input interface{} + expected string + } + + tests := []testCase{ + // basic types + {"string", "hello", "hello"}, + {"empty_string", "", ""}, + {"nil", nil, ""}, + + // integer types + {"int", 42, "42"}, + {"int8", int8(8), "8"}, + {"int16", int16(16), "16"}, + {"int32", int32(32), "32"}, + {"int64", int64(64), "64"}, + {"uint", uint(42), "42"}, + {"uint8", uint8(8), "8"}, + {"uint16", uint16(16), "16"}, + {"uint32", uint32(32), "32"}, + {"uint64", uint64(64), "64"}, + + // float types + {"float32", float32(3.14), "3.14"}, + {"float64", 3.14159, "3.14159"}, + + // boolean + {"bool_true", true, "true"}, + {"bool_false", false, "false"}, + + // special types + {"time", now, now.String()}, + {"address", addr, string(addr)}, + {"bytes", []byte("hello"), "hello"}, + {"stringer", stringer, "test:hello"}, + + // slices + {"empty_slice", []string{}, "[]"}, + {"string_slice", []string{"a", "b"}, "[a b]"}, + {"int_slice", []int{1, 2}, "[1 2]"}, + {"int32_slice", []int32{1, 2}, "[1 2]"}, + {"int64_slice", []int64{1, 2}, "[1 2]"}, + {"float32_slice", []float32{1.1, 2.2}, "[1.1 2.2]"}, + {"float64_slice", []float64{1.1, 2.2}, "[1.1 2.2]"}, + {"bytes_slice", [][]byte{[]byte("a"), []byte("b")}, "[a b]"}, + {"time_slice", []time.Time{now, now}, "[" + now.String() + " " + now.String() + "]"}, + {"address_slice", []std.Address{addr, addr}, "[" + string(addr) + " " + string(addr) + "]"}, + {"interface_slice", []interface{}{1, "a", true}, "[1 a true]"}, + + // empty slices + {"empty_string_slice", []string{}, "[]"}, + {"empty_int_slice", []int{}, "[]"}, + {"empty_int32_slice", []int32{}, "[]"}, + {"empty_int64_slice", []int64{}, "[]"}, + {"empty_float32_slice", []float32{}, "[]"}, + {"empty_float64_slice", []float64{}, "[]"}, + {"empty_bytes_slice", [][]byte{}, "[]"}, + {"empty_time_slice", []time.Time{}, "[]"}, + {"empty_address_slice", []std.Address{}, "[]"}, + {"empty_interface_slice", []interface{}{}, "[]"}, + + // maps + {"empty_string_map", map[string]string{}, "map[]"}, + {"string_map", map[string]string{"a": "1", "b": "2"}, "map[a:1 b:2]"}, + {"empty_interface_map", map[string]interface{}{}, "map[]"}, + {"interface_map", map[string]interface{}{"a": 1, "b": "2"}, "map[a:1 b:2]"}, + + // edge cases + {"empty_bytes", []byte{}, ""}, + {"nil_interface", interface{}(nil), ""}, + {"empty_struct", struct{}{}, "{}"}, + {"unknown_type", struct{ foo string }{}, ""}, + + // pointer types + {"nil_string_ptr", (*string)(nil), ""}, + {"string_ptr", &str, "hello"}, + {"nil_int_ptr", (*int)(nil), ""}, + {"int_ptr", &num, "42"}, + {"nil_bool_ptr", (*bool)(nil), ""}, + {"bool_ptr", &b, "true"}, + // {"nil_time_ptr", (*time.Time)(nil), ""}, // TODO: fix this + {"time_ptr", &now, now.String()}, + // {"nil_address_ptr", (*std.Address)(nil), ""}, // TODO: fix this + {"address_ptr", &addr, string(addr)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ToString(tt.input) + if got != tt.expected { + t.Errorf("%s: ToString(%v) = %q, want %q", tt.name, tt.input, got, tt.expected) + } + }) + } +} + +func TestToBool(t *testing.T) { + str := "true" + num := 42 + b := true + now := time.Now() + addr := std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") + zero := 0 + empty := "" + falseVal := false + + type testCase struct { + name string + input interface{} + expected bool + } + + tests := []testCase{ + // basic types + {"true", true, true}, + {"false", false, false}, + {"nil", nil, false}, + + // strings + {"empty_string", "", false}, + {"zero_string", "0", false}, + {"false_string", "false", false}, + {"f_string", "f", false}, + {"no_string", "no", false}, + {"n_string", "n", false}, + {"off_string", "off", false}, + {"space_string", " ", false}, + {"true_string", "true", true}, + {"yes_string", "yes", true}, + {"random_string", "hello", true}, + + // numbers + {"zero_int", 0, false}, + {"positive_int", 1, true}, + {"negative_int", -1, true}, + {"zero_float", 0.0, false}, + {"positive_float", 0.1, true}, + {"negative_float", -0.1, true}, + + // special types + {"empty_bytes", []byte{}, false}, + {"non_empty_bytes", []byte{1}, true}, + /*{"zero_time", time.Time{}, false},*/ // TODO: fix this + {"empty_address", std.Address(""), false}, + + // slices + {"empty_slice", []string{}, false}, + {"non_empty_slice", []string{"a"}, true}, + + // maps + {"empty_map", map[string]string{}, false}, + {"non_empty_map", map[string]string{"a": "b"}, true}, + + // pointer types + {"nil_bool_ptr", (*bool)(nil), false}, + {"true_ptr", &b, true}, + {"false_ptr", &falseVal, false}, + {"nil_string_ptr", (*string)(nil), false}, + {"string_ptr", &str, true}, + {"empty_string_ptr", &empty, false}, + {"nil_int_ptr", (*int)(nil), false}, + {"int_ptr", &num, true}, + {"zero_int_ptr", &zero, false}, + // {"nil_time_ptr", (*time.Time)(nil), false}, // TODO: fix this + {"time_ptr", &now, true}, + // {"nil_address_ptr", (*std.Address)(nil), false}, // TODO: fix this + {"address_ptr", &addr, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ToBool(tt.input) + if got != tt.expected { + t.Errorf("%s: ToBool(%v) = %v, want %v", tt.name, tt.input, got, tt.expected) + } + }) + } +} + +func TestIsZero(t *testing.T) { + str := "hello" + num := 42 + b := true + now := time.Now() + addr := std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") + zero := 0 + empty := "" + falseVal := false + + type testCase struct { + name string + input interface{} + expected bool + } + + tests := []testCase{ + // basic types + {"true", true, false}, + {"false", false, true}, + {"nil", nil, true}, + + // strings + {"empty_string", "", true}, + {"non_empty_string", "hello", false}, + + // numbers + {"zero_int", 0, true}, + {"non_zero_int", 1, false}, + {"zero_float", 0.0, true}, + {"non_zero_float", 0.1, false}, + + // special types + {"empty_bytes", []byte{}, true}, + {"non_empty_bytes", []byte{1}, false}, + /*{"zero_time", time.Time{}, true},*/ // TODO: fix this + {"empty_address", std.Address(""), true}, + + // slices + {"empty_slice", []string{}, true}, + {"non_empty_slice", []string{"a"}, false}, + + // maps + {"empty_map", map[string]string{}, true}, + {"non_empty_map", map[string]string{"a": "b"}, false}, + + // pointer types + {"nil_bool_ptr", (*bool)(nil), true}, + {"false_ptr", &falseVal, true}, + {"true_ptr", &b, false}, + {"nil_string_ptr", (*string)(nil), true}, + {"empty_string_ptr", &empty, true}, + {"string_ptr", &str, false}, + {"nil_int_ptr", (*int)(nil), true}, + {"zero_int_ptr", &zero, true}, + {"int_ptr", &num, false}, + // {"nil_time_ptr", (*time.Time)(nil), true}, // TODO: fix this + {"time_ptr", &now, false}, + // {"nil_address_ptr", (*std.Address)(nil), true}, // TODO: fix this + {"address_ptr", &addr, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := IsZero(tt.input) + if got != tt.expected { + t.Errorf("%s: IsZero(%v) = %v, want %v", tt.name, tt.input, got, tt.expected) + } + }) + } +} + +func TestToInterfaceSlice(t *testing.T) { + now := time.Now() + addr := std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") + str := testStringer{value: "hello"} + + tests := []struct { + name string + input interface{} + expected []interface{} + compare func([]interface{}, []interface{}) bool + }{ + { + name: "nil", + input: nil, + expected: nil, + compare: compareNil, + }, + { + name: "empty_interface_slice", + input: []interface{}{}, + expected: []interface{}{}, + compare: compareEmpty, + }, + { + name: "interface_slice", + input: []interface{}{1, "two", true}, + expected: []interface{}{1, "two", true}, + compare: compareInterfaces, + }, + { + name: "string_slice", + input: []string{"a", "b", "c"}, + expected: []interface{}{"a", "b", "c"}, + compare: compareStrings, + }, + { + name: "int_slice", + input: []int{1, 2, 3}, + expected: []interface{}{1, 2, 3}, + compare: compareInts, + }, + { + name: "int32_slice", + input: []int32{1, 2, 3}, + expected: []interface{}{int32(1), int32(2), int32(3)}, + compare: compareInt32s, + }, + { + name: "int64_slice", + input: []int64{1, 2, 3}, + expected: []interface{}{int64(1), int64(2), int64(3)}, + compare: compareInt64s, + }, + { + name: "float32_slice", + input: []float32{1.1, 2.2, 3.3}, + expected: []interface{}{float32(1.1), float32(2.2), float32(3.3)}, + compare: compareFloat32s, + }, + { + name: "float64_slice", + input: []float64{1.1, 2.2, 3.3}, + expected: []interface{}{1.1, 2.2, 3.3}, + compare: compareFloat64s, + }, + { + name: "bool_slice", + input: []bool{true, false, true}, + expected: []interface{}{true, false, true}, + compare: compareBools, + }, + /* { + name: "time_slice", + input: []time.Time{now}, + expected: []interface{}{now}, + compare: compareTimes, + }, */ // TODO: fix this + /* { + name: "address_slice", + input: []std.Address{addr}, + expected: []interface{}{addr}, + compare: compareAddresses, + },*/ // TODO: fix this + /* { + name: "bytes_slice", + input: [][]byte{[]byte("hello"), []byte("world")}, + expected: []interface{}{[]byte("hello"), []byte("world")}, + compare: compareBytes, + },*/ // TODO: fix this + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ToInterfaceSlice(tt.input) + if !tt.compare(got, tt.expected) { + t.Errorf("ToInterfaceSlice() = %v, want %v", got, tt.expected) + } + }) + } +} + +func compareNil(a, b []interface{}) bool { + return a == nil && b == nil +} + +func compareEmpty(a, b []interface{}) bool { + return len(a) == 0 && len(b) == 0 +} + +func compareInterfaces(a, b []interface{}) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func compareStrings(a, b []interface{}) bool { + if len(a) != len(b) { + return false + } + for i := range a { + as, ok1 := a[i].(string) + bs, ok2 := b[i].(string) + if !ok1 || !ok2 || as != bs { + return false + } + } + return true +} + +func compareInts(a, b []interface{}) bool { + if len(a) != len(b) { + return false + } + for i := range a { + ai, ok1 := a[i].(int) + bi, ok2 := b[i].(int) + if !ok1 || !ok2 || ai != bi { + return false + } + } + return true +} + +func compareInt32s(a, b []interface{}) bool { + if len(a) != len(b) { + return false + } + for i := range a { + ai, ok1 := a[i].(int32) + bi, ok2 := b[i].(int32) + if !ok1 || !ok2 || ai != bi { + return false + } + } + return true +} + +func compareInt64s(a, b []interface{}) bool { + if len(a) != len(b) { + return false + } + for i := range a { + ai, ok1 := a[i].(int64) + bi, ok2 := b[i].(int64) + if !ok1 || !ok2 || ai != bi { + return false + } + } + return true +} + +func compareFloat32s(a, b []interface{}) bool { + if len(a) != len(b) { + return false + } + for i := range a { + ai, ok1 := a[i].(float32) + bi, ok2 := b[i].(float32) + if !ok1 || !ok2 || ai != bi { + return false + } + } + return true +} + +func compareFloat64s(a, b []interface{}) bool { + if len(a) != len(b) { + return false + } + for i := range a { + ai, ok1 := a[i].(float64) + bi, ok2 := b[i].(float64) + if !ok1 || !ok2 || ai != bi { + return false + } + } + return true +} + +func compareBools(a, b []interface{}) bool { + if len(a) != len(b) { + return false + } + for i := range a { + ab, ok1 := a[i].(bool) + bb, ok2 := b[i].(bool) + if !ok1 || !ok2 || ab != bb { + return false + } + } + return true +} + +func compareTimes(a, b []interface{}) bool { + if len(a) != len(b) { + return false + } + for i := range a { + at, ok1 := a[i].(time.Time) + bt, ok2 := b[i].(time.Time) + if !ok1 || !ok2 || !at.Equal(bt) { + return false + } + } + return true +} + +func compareAddresses(a, b []interface{}) bool { + if len(a) != len(b) { + return false + } + for i := range a { + aa, ok1 := a[i].(std.Address) + ba, ok2 := b[i].(std.Address) + if !ok1 || !ok2 || aa != ba { + return false + } + } + return true +} + +func compareBytes(a, b []interface{}) bool { + if len(a) != len(b) { + return false + } + for i := range a { + ab, ok1 := a[i].([]byte) + bb, ok2 := b[i].([]byte) + if !ok1 || !ok2 || string(ab) != string(bb) { + return false + } + } + return true +} + +// compareStringInterfaceMaps compares two map[string]interface{} for equality +func compareStringInterfaceMaps(a, b map[string]interface{}) bool { + if len(a) != len(b) { + return false + } + for k, v1 := range a { + v2, ok := b[k] + if !ok { + return false + } + // Compare values based on their type + switch val1 := v1.(type) { + case string: + val2, ok := v2.(string) + if !ok || val1 != val2 { + return false + } + case int: + val2, ok := v2.(int) + if !ok || val1 != val2 { + return false + } + case float64: + val2, ok := v2.(float64) + if !ok || val1 != val2 { + return false + } + case bool: + val2, ok := v2.(bool) + if !ok || val1 != val2 { + return false + } + case []interface{}: + val2, ok := v2.([]interface{}) + if !ok || len(val1) != len(val2) { + return false + } + for i := range val1 { + if val1[i] != val2[i] { + return false + } + } + case map[string]interface{}: + val2, ok := v2.(map[string]interface{}) + if !ok || !compareStringInterfaceMaps(val1, val2) { + return false + } + default: + return false + } + } + return true +} + +func TestToMapStringInterface(t *testing.T) { + tests := []struct { + name string + input interface{} + expected map[string]interface{} + wantErr bool + }{ + { + name: "map[string]interface{}", + input: map[string]interface{}{ + "key1": "value1", + "key2": 42, + }, + expected: map[string]interface{}{ + "key1": "value1", + "key2": 42, + }, + wantErr: false, + }, + { + name: "map[string]string", + input: map[string]string{ + "key1": "value1", + "key2": "value2", + }, + expected: map[string]interface{}{ + "key1": "value1", + "key2": "value2", + }, + wantErr: false, + }, + { + name: "map[string]int", + input: map[string]int{ + "key1": 1, + "key2": 2, + }, + expected: map[string]interface{}{ + "key1": 1, + "key2": 2, + }, + wantErr: false, + }, + { + name: "map[string]float64", + input: map[string]float64{ + "key1": 1.1, + "key2": 2.2, + }, + expected: map[string]interface{}{ + "key1": 1.1, + "key2": 2.2, + }, + wantErr: false, + }, + { + name: "map[string]bool", + input: map[string]bool{ + "key1": true, + "key2": false, + }, + expected: map[string]interface{}{ + "key1": true, + "key2": false, + }, + wantErr: false, + }, + { + name: "map[string][]string", + input: map[string][]string{ + "key1": {"a", "b"}, + "key2": {"c", "d"}, + }, + expected: map[string]interface{}{ + "key1": []interface{}{"a", "b"}, + "key2": []interface{}{"c", "d"}, + }, + wantErr: false, + }, + { + name: "nested map[string]map[string]string", + input: map[string]map[string]string{ + "key1": {"nested1": "value1"}, + "key2": {"nested2": "value2"}, + }, + expected: map[string]interface{}{ + "key1": map[string]interface{}{"nested1": "value1"}, + "key2": map[string]interface{}{"nested2": "value2"}, + }, + wantErr: false, + }, + { + name: "unsupported type", + input: 42, // not a map + expected: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ToMapStringInterface(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("ToMapStringInterface() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + if !compareStringInterfaceMaps(got, tt.expected) { + t.Errorf("ToMapStringInterface() = %v, expected %v", got, tt.expected) + } + } + }) + } +} + +// Test error messages +func TestToMapStringInterfaceErrors(t *testing.T) { + _, err := ToMapStringInterface(42) + if err == nil || !strings.Contains(err.Error(), "unsupported map type") { + t.Errorf("Expected error containing 'unsupported map type', got %v", err) + } +} + +// compareIntInterfaceMaps compares two map[int]interface{} for equality +func compareIntInterfaceMaps(a, b map[int]interface{}) bool { + if len(a) != len(b) { + return false + } + for k, v1 := range a { + v2, ok := b[k] + if !ok { + return false + } + // Compare values based on their type + switch val1 := v1.(type) { + case string: + val2, ok := v2.(string) + if !ok || val1 != val2 { + return false + } + case int: + val2, ok := v2.(int) + if !ok || val1 != val2 { + return false + } + case float64: + val2, ok := v2.(float64) + if !ok || val1 != val2 { + return false + } + case bool: + val2, ok := v2.(bool) + if !ok || val1 != val2 { + return false + } + case []interface{}: + val2, ok := v2.([]interface{}) + if !ok || len(val1) != len(val2) { + return false + } + for i := range val1 { + if val1[i] != val2[i] { + return false + } + } + case map[string]interface{}: + val2, ok := v2.(map[string]interface{}) + if !ok || !compareStringInterfaceMaps(val1, val2) { + return false + } + default: + return false + } + } + return true +} + +func TestToMapIntInterface(t *testing.T) { + tests := []struct { + name string + input interface{} + expected map[int]interface{} + wantErr bool + }{ + { + name: "map[int]interface{}", + input: map[int]interface{}{ + 1: "value1", + 2: 42, + }, + expected: map[int]interface{}{ + 1: "value1", + 2: 42, + }, + wantErr: false, + }, + { + name: "map[int]string", + input: map[int]string{ + 1: "value1", + 2: "value2", + }, + expected: map[int]interface{}{ + 1: "value1", + 2: "value2", + }, + wantErr: false, + }, + { + name: "map[int]int", + input: map[int]int{ + 1: 10, + 2: 20, + }, + expected: map[int]interface{}{ + 1: 10, + 2: 20, + }, + wantErr: false, + }, + { + name: "map[int]float64", + input: map[int]float64{ + 1: 1.1, + 2: 2.2, + }, + expected: map[int]interface{}{ + 1: 1.1, + 2: 2.2, + }, + wantErr: false, + }, + { + name: "map[int]bool", + input: map[int]bool{ + 1: true, + 2: false, + }, + expected: map[int]interface{}{ + 1: true, + 2: false, + }, + wantErr: false, + }, + { + name: "map[int][]string", + input: map[int][]string{ + 1: {"a", "b"}, + 2: {"c", "d"}, + }, + expected: map[int]interface{}{ + 1: []interface{}{"a", "b"}, + 2: []interface{}{"c", "d"}, + }, + wantErr: false, + }, + { + name: "map[int]map[string]interface{}", + input: map[int]map[string]interface{}{ + 1: {"nested1": "value1"}, + 2: {"nested2": "value2"}, + }, + expected: map[int]interface{}{ + 1: map[string]interface{}{"nested1": "value1"}, + 2: map[string]interface{}{"nested2": "value2"}, + }, + wantErr: false, + }, + { + name: "unsupported type", + input: 42, // not a map + expected: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ToMapIntInterface(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("ToMapIntInterface() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + if !compareIntInterfaceMaps(got, tt.expected) { + t.Errorf("ToMapIntInterface() = %v, expected %v", got, tt.expected) + } + } + }) + } +} + +func TestToStringSlice(t *testing.T) { + tests := []struct { + name string + input interface{} + expected []string + }{ + { + name: "nil input", + input: nil, + expected: nil, + }, + { + name: "empty slice", + input: []string{}, + expected: []string{}, + }, + { + name: "string slice", + input: []string{"a", "b", "c"}, + expected: []string{"a", "b", "c"}, + }, + { + name: "int slice", + input: []int{1, 2, 3}, + expected: []string{"1", "2", "3"}, + }, + { + name: "int32 slice", + input: []int32{1, 2, 3}, + expected: []string{"1", "2", "3"}, + }, + { + name: "int64 slice", + input: []int64{1, 2, 3}, + expected: []string{"1", "2", "3"}, + }, + { + name: "uint slice", + input: []uint{1, 2, 3}, + expected: []string{"1", "2", "3"}, + }, + { + name: "uint8 slice", + input: []uint8{1, 2, 3}, + expected: []string{"1", "2", "3"}, + }, + { + name: "uint16 slice", + input: []uint16{1, 2, 3}, + expected: []string{"1", "2", "3"}, + }, + { + name: "uint32 slice", + input: []uint32{1, 2, 3}, + expected: []string{"1", "2", "3"}, + }, + { + name: "uint64 slice", + input: []uint64{1, 2, 3}, + expected: []string{"1", "2", "3"}, + }, + { + name: "float32 slice", + input: []float32{1.1, 2.2, 3.3}, + expected: []string{"1.1", "2.2", "3.3"}, + }, + { + name: "float64 slice", + input: []float64{1.1, 2.2, 3.3}, + expected: []string{"1.1", "2.2", "3.3"}, + }, + { + name: "bool slice", + input: []bool{true, false, true}, + expected: []string{"true", "false", "true"}, + }, + { + name: "[]byte slice", + input: [][]byte{[]byte("hello"), []byte("world")}, + expected: []string{"hello", "world"}, + }, + { + name: "interface slice", + input: []interface{}{1, "hello", true}, + expected: []string{"1", "hello", "true"}, + }, + { + name: "time slice", + input: []time.Time{time.Time{}, time.Time{}}, + expected: []string{"0001-01-01 00:00:00 +0000 UTC", "0001-01-01 00:00:00 +0000 UTC"}, + }, + { + name: "address slice", + input: []std.Address{"addr1", "addr2"}, + expected: []string{"addr1", "addr2"}, + }, + { + name: "non-slice input", + input: 42, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ToStringSlice(tt.input) + if !slicesEqual(result, tt.expected) { + t.Errorf("ToStringSlice(%v) = %v, want %v", tt.input, result, tt.expected) + } + }) + } +} + +// Helper function to compare string slices +func slicesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func TestToStringAdvanced(t *testing.T) { + tests := []struct { + name string + input interface{} + expected string + }{ + { + name: "slice with mixed basic types", + input: []interface{}{ + 42, + "hello", + true, + 3.14, + }, + expected: "[42 hello true 3.14]", + }, + { + name: "map with basic types", + input: map[string]interface{}{ + "int": 42, + "str": "hello", + "bool": true, + "float": 3.14, + }, + expected: "map[bool:true float:3.14 int:42 str:hello]", + }, + { + name: "mixed types map", + input: map[interface{}]interface{}{ + 42: "number", + "string": 123, + true: []int{1, 2, 3}, + struct{}{}: "empty", + }, + expected: "map[42:number string:123 true:[1 2 3] {}:empty]", + }, + { + name: "nested maps", + input: map[string]interface{}{ + "a": map[string]int{ + "x": 1, + "y": 2, + }, + "b": []interface{}{1, "two", true}, + }, + expected: "map[a:map[x:1 y:2] b:[1 two true]]", + }, + { + name: "empty struct", + input: struct{}{}, + expected: "{}", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ToString(tt.input) + if result != tt.expected { + t.Errorf("\nToString(%v) =\n%v\nwant:\n%v", tt.input, result, tt.expected) + } + }) + } +} From 7da2415824a7555c55bd3977c39fb2d2a5fcb7db Mon Sep 17 00:00:00 2001 From: moul <94029+moul@users.noreply.github.com> Date: Fri, 20 Dec 2024 15:00:31 +0100 Subject: [PATCH 2/2] chore: fixup Signed-off-by: moul <94029+moul@users.noreply.github.com> --- .../gno.land/p/moul/template/template.gno | 731 ++++++++---------- 1 file changed, 328 insertions(+), 403 deletions(-) diff --git a/examples/gno.land/p/moul/template/template.gno b/examples/gno.land/p/moul/template/template.gno index 9ffc1d182f2..6a9781e5048 100644 --- a/examples/gno.land/p/moul/template/template.gno +++ b/examples/gno.land/p/moul/template/template.gno @@ -10,14 +10,13 @@ import ( "gno.land/p/moul/typeutil" ) -// Add at the top of the file, after package declaration +// Enable/disable debug logs var debug bool = false func log(args ...interface{}) { if !debug { return } - // Convert args to strings and join them var s string for i, arg := range args { if i > 0 { @@ -36,7 +35,7 @@ const ( kindElse ) -// Context management +// context represents the current "range" or "if" scope type context struct { parent *context currentRange []interface{} @@ -45,7 +44,7 @@ type context struct { kind int } -// Renderer is the main template processor +// Renderer is our main template struct type Renderer struct { helpers map[string]Helper data map[string]interface{} @@ -59,7 +58,6 @@ func NewRenderer() *Renderer { helpers: make(map[string]Helper), data: make(map[string]interface{}), } - // Register markdown helpers r.helpers["H1"] = SingleArgHelper{fn: md.H1} r.helpers["H2"] = SingleArgHelper{fn: md.H2} @@ -85,136 +83,331 @@ func NewRenderer() *Renderer { r.helpers["Paragraph"] = SingleArgHelper{fn: md.Paragraph} r.helpers["CollapsibleSection"] = TwoArgHelper{fn: md.CollapsibleSection} - // Register other helpers (in separate category) + // Register other helpers r.helpers["concat"] = VarArgHelper{fn: concatHelper} - return r } +// Render processes the passed-in template string and returns the rendered result +func (r *Renderer) Render(template string, data map[string]interface{}) string { + if data != nil { + r.data = data + } + r.lastContent = template + + var result strings.Builder + i := 0 + for i < len(template) { + // Look for a "{{" + if i+1 < len(template) && template[i] == '{' && template[i+1] == '{' { + // Write out anything before the "{{" + placeholderStart := i + result.WriteString(template[:placeholderStart]) + + // Skip "{{" + i += 2 + + // Parse out the placeholder content until matching "}}" + inner, trimLeft, trimRight, endPos := r.parsePlaceholder(template, i) + if endPos < 0 { + // No matching }} found; write the rest as-is and break + result.WriteString(template[placeholderStart:]) + break + } + // Move i to after "}}" + i = endPos + 2 + + // Trim left (remove trailing spaces in result) + if trimLeft { + resultString := result.String() + result.Reset() + result.WriteString(strings.TrimRightFunc(resultString, unicode.IsSpace)) + } + + // Process that placeholder + output, err := r.processPlaceholder(inner) + if err != nil { + // In debug mode we might display these errors, or just inline them + output = fmt.Sprintf("[error: %v]", err) + } + result.WriteString(output) + + // Trim right from the source template (i.e., skip spaces in template) + if trimRight { + for i < len(template) && unicode.IsSpace(rune(template[i])) { + i++ + } + } + + // The chunk is fully handled, so shift template to skip placeholder + template = template[i:] + i = 0 // reset to parse from beginning of remainder + + } else { + // Normal character, just move forward + i++ + } + } + + // If there's any remainder after last placeholder, write it out + result.WriteString(template) + return result.String() +} + +// parsePlaceholder extracts the placeholder content between "{{" and "}}", +// also checks for the optional trim markers "-}}", "{{-". +func (r *Renderer) parsePlaceholder(template string, start int) (inner string, trimLeft bool, trimRight bool, endPos int) { + // We'll parse until we match the next "}}" (noting we might skip nested "{{...}}") + depth := 1 + j := start + for j < len(template)-1 { + if template[j] == '{' && template[j+1] == '{' { + depth++ + j += 2 + } else if template[j] == '}' && template[j+1] == '}' { + depth-- + if depth == 0 { + break + } + j += 2 + } else { + j++ + } + } + if depth != 0 { + return "", false, false, -1 // no matching "}}" + } + + raw := template[start:j] + + // Check for trim markers + raw = strings.TrimSpace(raw) + if strings.HasPrefix(raw, "-") { + trimLeft = true + raw = strings.TrimPrefix(raw, "-") + raw = strings.TrimLeftFunc(raw, unicode.IsSpace) + } + if strings.HasSuffix(raw, "-") { + trimRight = true + raw = strings.TrimSuffix(raw, "-") + raw = strings.TrimRightFunc(raw, unicode.IsSpace) + } + + return strings.TrimSpace(raw), trimLeft, trimRight, j +} + +// processPlaceholder decides how to handle the placeholder logic: variable, range, if, helper, etc. func (r *Renderer) processPlaceholder(content string) (string, error) { parts := splitParts(content) if len(parts) == 0 { return "", nil } - - // First check if it's just a variable reference (starts with dot) + // If the content starts with "." it might be a variable if strings.HasPrefix(parts[0], ".") { val, ok := r.getVar(parts[0]) if ok { return typeutil.ToString(val), nil } - // Return empty string if variable not found instead of the variable name return "", nil } switch parts[0] { case "range": - log("=== Processing range placeholder ===") - log("Content:", content) if len(parts) < 2 { return "", errors.New("range requires a variable") } - val, ok := r.getVar(parts[1]) - if !ok { - log("Variable not found:", parts[1]) - return "", nil + return r.handleRange(parts[1:]) + case "end": + // "end" should be handled inside handleRange or handleIf, so normally we wouldn't get here. + return "", nil + case "if": + if len(parts) < 2 { + return "", fmt.Errorf("if requires a condition") } - log("Variable value:", val) - - rangeContent := r.extractRangeContent(strings.Join(parts[1:], " ")) - log("Extracted range content:", rangeContent) - if rangeContent == "" { - return "", fmt.Errorf("could not find range content") + return r.handleIf(parts[1:]) + default: + // Possibly a helper + if helper, ok := r.helpers[parts[0]]; ok { + return helper.Execute(parts[1:], r.data), nil } + // Otherwise just treat as plain text + return strings.Join(parts, " "), nil + } +} - var result strings.Builder - var rangeData []interface{} +// handleRange scans forward in r.lastContent to find the matching {{ end }} block content, +// then iterates over each item in the slice, calling subrender. +func (r *Renderer) handleRange(exprParts []string) (string, error) { + val, ok := r.getVar("." + strings.TrimPrefix(exprParts[0], ".")) // safe fix in case user used .Var or Var + if !ok { + return "", nil + } - switch v := val.(type) { - case []interface{}: - rangeData = v - case []map[string]interface{}: - rangeData = make([]interface{}, len(v)) - for i, m := range v { - rangeData[i] = m - } - default: - if slice := typeutil.ToInterfaceSlice(v); slice != nil { - rangeData = slice - } else { - return "", fmt.Errorf("cannot range over %T", val) - } + // We want to collect the content between "{{ range ... }}" and the matching "{{ end }}" + rangeBlock, err := r.extractBlock("range "+strings.Join(exprParts, " "), "end") + if err != nil { + return "", err + } + + var rangeData []interface{} + switch v := val.(type) { + case []interface{}: + rangeData = v + case []map[string]interface{}: + rangeData = make([]interface{}, len(v)) + for i, m := range v { + rangeData[i] = m + } + default: + if slice := typeutil.ToInterfaceSlice(v); slice != nil { + rangeData = slice + } else { + return "", fmt.Errorf("cannot range over %T", val) } + } - for i, item := range rangeData { - newContext := &context{ - parent: r.context, - currentRange: rangeData, - rangeIndex: i, - kind: kindRange, - rangeMap: nil, - } + var result strings.Builder + for i, item := range rangeData { + newCtx := &context{ + parent: r.context, + kind: kindRange, + currentRange: rangeData, + rangeIndex: i, + } + if m, isMap := item.(map[string]interface{}); isMap { + newCtx.rangeMap = m + } - if m, ok := item.(map[string]interface{}); ok { - newContext.rangeMap = m - } + subRenderer := &Renderer{ + helpers: r.helpers, + data: r.data, + context: newCtx, + } + subResult := subRenderer.Render(rangeBlock, nil) + result.WriteString(subResult) + } - subRenderer := &Renderer{ - helpers: r.helpers, - data: r.data, - context: newContext, - } + return result.String(), nil +} - processed := subRenderer.Render(rangeContent, nil) - result.WriteString(processed) +// handleIf scans forward in r.lastContent to find the matching {{ end }} (and possible {{ else }}), +// renders the relevant block if condition is true or false. +func (r *Renderer) handleIf(exprParts []string) (string, error) { + condVal, ok := r.getVar("." + strings.TrimPrefix(exprParts[0], ".")) + if !ok { + condVal = false + } + condition := typeutil.ToBool(condVal) + fullBlock, err := r.extractBlock("if "+strings.Join(exprParts, " "), "end") + if err != nil { + return "", err + } + + // Try to find an {{ else }} within that block to split it + elseIdx := -1 + depth := 0 + for i := 0; i+2 < len(fullBlock); i++ { + if strings.HasPrefix(fullBlock[i:], "{{") { + if strings.HasPrefix(fullBlock[i+2:], " if ") { + depth++ + } else if strings.HasPrefix(fullBlock[i+2:], " end") { + // match: reduce depth + depth-- + } else if depth == 0 && strings.HasPrefix(fullBlock[i+2:], " else") { + elseIdx = i + break + } } + } - log("Final range result:", result.String()) - return result.String(), nil + var ifBlock, elseBlock string + if elseIdx >= 0 { + ifBlock = fullBlock[:elseIdx] + // skip the "{{ else }}" placeholder + endOfElse := elseIdx + for endOfElse < len(fullBlock) && fullBlock[endOfElse:endOfElse+2] != "}}" { + endOfElse++ + } + endOfElse += 2 + elseBlock = fullBlock[endOfElse:] + } else { + ifBlock = fullBlock + } - case "end": - return "", nil + newCtx := &context{ + parent: r.context, + kind: kindIf, + } + subRenderer := &Renderer{ + helpers: r.helpers, + data: r.data, + context: newCtx, + } - case "if": - if len(parts) < 2 { - return "", fmt.Errorf("if requires a condition") - } - val, ok := r.getVar(parts[1]) - if !ok { - val = false - } - condition := typeutil.ToBool(val) + if condition { + return subRenderer.Render(ifBlock, nil), nil + } else { + return subRenderer.Render(elseBlock, nil), nil + } +} - ifContent := r.extractIfContent(strings.Join(parts[1:], " ")) - if ifContent == "" { - return "", nil +// extractBlock finds the text between "{{ range/if expr }}" and the matching "{{ end }}". +// We rely on r.lastContent to find the entire block so that nested placeholders can be found. +func (r *Renderer) extractBlock(startExpr, endExpr string) (string, error) { + // Look for the exact "{{ startExpr }}" and keep track of nested blocks until we find the matching "{{ endExpr }}" + startMarker1 := "{{ " + startExpr + " }}" + startMarker2 := "{{" + startExpr + "}}" // fallback no spaces + startIdx := strings.Index(r.lastContent, startMarker1) + if startIdx < 0 { + startIdx = strings.Index(r.lastContent, startMarker2) + if startIdx < 0 { + return "", fmt.Errorf("could not find start marker for %q", startExpr) } + } + blockStart := startIdx + len(startMarker1) + if strings.Contains(r.lastContent[startIdx:startIdx+len(startMarker1)], startExpr) == false { + // If we matched the fallback startMarker2, adjust blockStart + blockStart = startIdx + len(startMarker2) + } - if condition { - newContext := &context{ - parent: r.context, - kind: kindIf, + depth := 1 + i := blockStart + var block strings.Builder + + for i < len(r.lastContent)-1 { + // if we see "{{" + if r.lastContent[i] == '{' && r.lastContent[i+1] == '{' { + // check what follows + innerStart := i + 2 + // find matching "}}" + _, _, _, matchPos := r.parsePlaceholder(r.lastContent, innerStart) + if matchPos < 0 { + // no matching "}}", break + break } + innerContent := strings.TrimSpace(r.lastContent[innerStart:matchPos]) - subRenderer := &Renderer{ - helpers: r.helpers, - data: r.data, - context: newContext, - lastContent: ifContent, + // check if it's a nested start + if strings.HasPrefix(innerContent, "range ") || strings.HasPrefix(innerContent, "if ") { + depth++ + } else if strings.HasPrefix(innerContent, endExpr) { + depth-- + if depth == 0 { + // done, skip past that end + i = matchPos + 2 + break + } } - return subRenderer.Render(ifContent, nil), nil - } - return "", nil - - default: - // Check for helpers - if helper, ok := r.helpers[parts[0]]; ok { - return helper.Execute(parts[1:], r.data), nil + block.WriteString(r.lastContent[i : matchPos+2]) + i = matchPos + 2 + } else { + block.WriteByte(r.lastContent[i]) + i++ } - - // If not a helper and not a special command, treat as plain text - return strings.Join(parts, " "), nil } + + // Now we have the block content (minus the outside markers) + return block.String(), nil } // splitParts splits a template expression into parts, respecting quoted strings @@ -241,139 +434,29 @@ func splitParts(s string) []string { current.WriteByte(s[i]) } } - if current.Len() > 0 { parts = append(parts, current.String()) } - return parts } -func trimMarkers(content string) (string, bool, bool) { - content = strings.TrimSpace(content) - trimLeft := false - trimRight := false - - if strings.HasPrefix(content, "-") { - trimLeft = true - content = strings.TrimPrefix(content, "-") - content = strings.TrimLeftFunc(content, unicode.IsSpace) - } - if strings.HasSuffix(content, "-") { - trimRight = true - content = strings.TrimSuffix(content, "-") - content = strings.TrimRightFunc(content, unicode.IsSpace) - } - return strings.TrimSpace(content), trimLeft, trimRight -} - -// Render processes the template with the given data -func (r *Renderer) Render(template string, data map[string]interface{}) string { - if data != nil { - r.data = data - } - - var result strings.Builder - lastPos := 0 - r.lastContent = template - - for i := 0; i < len(template); { - if i+1 < len(template) && template[i] == '{' && template[i+1] == '{' { - end := i + 2 - depth := 1 - for end < len(template)-1 { - if template[end] == '}' && template[end+1] == '}' { - depth-- - if depth == 0 { - break - } - } else if template[end] == '{' && template[end+1] == '{' { - depth++ - } - end++ - } - - if end < len(template)-1 { - content := template[i+2 : end] - content, trimLeft, trimRight := trimMarkers(content) - - if trimLeft { - result.WriteString(strings.TrimRightFunc(template[lastPos:i], unicode.IsSpace)) - } else { - result.WriteString(template[lastPos:i]) - } - - output, err := r.processPlaceholder(content) - if err != nil { - log("Error processing placeholder:", err) - return fmt.Sprintf("[error: %v]", err) - } - result.WriteString(output) - - i = end + 2 - lastPos = i - - if trimRight { - for lastPos < len(template) && unicode.IsSpace(rune(template[lastPos])) { - lastPos++ - i++ - } - } - continue - } - } - i++ - } - - if lastPos < len(template) { - result.WriteString(template[lastPos:]) - } - - return result.String() -} - -// Context management methods -func (r *Renderer) pushContext(data interface{}, kind int) { - nc := &context{ - parent: r.context, - kind: kind, - } - - switch v := data.(type) { - case []interface{}: - nc.currentRange = v - nc.rangeIndex = 0 - case map[string]interface{}: - nc.rangeMap = v - case bool: - nc.currentRange = []interface{}{v} - } - - r.context = nc -} - -func (r *Renderer) popContext() { - if r.context != nil { - r.context = r.context.parent - } -} - -// getVar retrieves a variable from the current context or data +// getVar retrieves a variable from context or global data func (r *Renderer) getVar(name string) (interface{}, bool) { if r.context != nil && r.context.kind == kindRange { + // Inside a range block if name == "." { if r.context.currentRange != nil && r.context.rangeIndex < len(r.context.currentRange) { return r.context.currentRange[r.context.rangeIndex], true } } else if strings.HasPrefix(name, ".") { name = strings.TrimPrefix(name, ".") - // First check rangeMap if available + // if we have a rangeMap if r.context.rangeMap != nil { - if val, exists := r.context.rangeMap[name]; exists { + if val, ok := r.context.rangeMap[name]; ok { return val, true } } - // Then check current range item if it's a map + // or if the item is a map if r.context.currentRange != nil && r.context.rangeIndex < len(r.context.currentRange) { if item, ok := r.context.currentRange[r.context.rangeIndex].(map[string]interface{}); ok { if val, exists := item[name]; exists { @@ -383,139 +466,96 @@ func (r *Renderer) getVar(name string) (interface{}, bool) { } } } - - // If not found in context, look in data + // fallback: look in global data return r.lookupVar(strings.TrimPrefix(name, ".")) } -// lookupVar looks up a variable in a map by name, supporting dot notation +// lookupVar supports dot-notation "nested.key" func (r *Renderer) lookupVar(name string) (interface{}, bool) { - // Split the name into parts for nested lookups parts := strings.Split(name, ".") - - current := r.data + cur := r.data for i, part := range parts { if i == len(parts)-1 { - // Last part - return the value - val, ok := current[part] + val, ok := cur[part] return val, ok } - - // Not the last part - must be a map - next, ok := current[part] + next, ok := cur[part] if !ok { return nil, false } - - // Convert to map for next iteration - nextMap, ok := next.(map[string]interface{}) - if !ok { + nextMap, isMap := next.(map[string]interface{}) + if !isMap { return nil, false } - current = nextMap + cur = nextMap } - return nil, false } -// Helper interface definition +// Helpers + +// Helper is an interface for template helpers type Helper interface { Execute(args []string, data map[string]interface{}) string } -// SingleArgHelper implementation -type SingleArgHelper struct { - fn func(string) string -} +type SingleArgHelper struct{ fn func(string) string } func (h SingleArgHelper) Execute(args []string, data map[string]interface{}) string { if len(args) < 1 { return "[error: missing argument]" } - - // Join all args for quoted strings that might contain spaces arg := strings.Join(args, " ") - - // Handle quoted strings properly - if strings.HasPrefix(arg, "\"") && strings.HasSuffix(arg, "\"") { - // Remove outer quotes and keep inner quotes as-is + // If it has outer quotes, strip them + if len(arg) >= 2 && arg[0] == '"' && arg[len(arg)-1] == '"' { arg = arg[1 : len(arg)-1] } - - return h.fn(arg) + return h.fn(strings.TrimSpace(arg)) } -// VarArgHelper implementation -type VarArgHelper struct { - fn func([]string) string -} +type VarArgHelper struct{ fn func([]string) string } func (h VarArgHelper) Execute(args []string, data map[string]interface{}) string { - processedArgs := make([]string, len(args)) + processed := make([]string, len(args)) for i, arg := range args { arg = strings.TrimSpace(arg) if strings.HasPrefix(arg, ".") { if val, ok := data[strings.TrimPrefix(arg, ".")]; ok { - processedArgs[i] = typeutil.ToString(val) + processed[i] = typeutil.ToString(val) continue } - } else if strings.HasPrefix(arg, "\"") && strings.HasSuffix(arg, "\"") { - processedArgs[i] = arg[1 : len(arg)-1] - continue } - processedArgs[i] = arg + // quoted? + if len(arg) >= 2 && arg[0] == '"' && arg[len(arg)-1] == '"' { + arg = arg[1 : len(arg)-1] + } + processed[i] = arg } - return h.fn(processedArgs) + return h.fn(processed) } -// TwoArgHelper implementation -type TwoArgHelper struct { - fn func(string, string) string -} +type TwoArgHelper struct{ fn func(string, string) string } func (h TwoArgHelper) Execute(args []string, data map[string]interface{}) string { if len(args) < 2 { return "[error: requires two arguments]" } - - // Process first argument - arg1 := args[0] - if strings.HasPrefix(arg1, "\"") && strings.HasSuffix(arg1, "\"") { - arg1 = arg1[1 : len(arg1)-1] - } - - // Process second argument - arg2 := args[1] - if strings.HasPrefix(arg2, "\"") && strings.HasSuffix(arg2, "\"") { - arg2 = arg2[1 : len(arg2)-1] - } - + arg1 := trimQuotes(args[0]) + arg2 := trimQuotes(args[1]) return h.fn(arg1, arg2) } -// TwoArgStringBoolHelper implementation -type TwoArgStringBoolHelper struct { - fn func(string, bool) string -} +type TwoArgStringBoolHelper struct{ fn func(string, bool) string } func (h TwoArgStringBoolHelper) Execute(args []string, data map[string]interface{}) string { if len(args) < 2 { return "[error: requires two arguments]" } - - // Process first argument as a string - arg1 := args[0] - if strings.HasPrefix(arg1, "\"") && strings.HasSuffix(arg1, "\"") { - arg1 = arg1[1 : len(arg1)-1] - } - - // Process second argument as a boolean + arg1 := trimQuotes(args[0]) arg2 := strings.ToLower(args[1]) == "true" - return h.fn(arg1, arg2) } -// ThreeArgHelper implementation type ThreeArgHelper struct { fn func(string, string, string) string } @@ -524,136 +564,21 @@ func (h ThreeArgHelper) Execute(args []string, data map[string]interface{}) stri if len(args) < 3 { return "[error: requires three arguments]" } - - // Process first argument - arg1 := args[0] - if strings.HasPrefix(arg1, "\"") && strings.HasSuffix(arg1, "\"") { - arg1 = arg1[1 : len(arg1)-1] - } - - // Process second argument - arg2 := args[1] - if strings.HasPrefix(arg2, "\"") && strings.HasSuffix(arg2, "\"") { - arg2 = arg2[1 : len(arg2)-1] - } - - // Process third argument - arg3 := args[2] - if strings.HasPrefix(arg3, "\"") && strings.HasSuffix(arg3, "\"") { - arg3 = arg3[1 : len(arg3)-1] - } - + arg1 := trimQuotes(args[0]) + arg2 := trimQuotes(args[1]) + arg3 := trimQuotes(args[2]) return h.fn(arg1, arg2, arg3) } -// Helper function for string concatenation +// Simple helper for string concatenation func concatHelper(args []string) string { return strings.Join(args, "") } -// New helper function to extract range content -func (r *Renderer) extractRangeContent(rangeExpr string) string { - log("=== extractRangeContent Debug ===") - log("Range Expression:", rangeExpr) - log("Last Content:", r.lastContent) - - // Look for the range start marker - marker := "{{ range " + strings.TrimSpace(rangeExpr) + " }}" - start := strings.Index(r.lastContent, marker) - if start < 0 { - // Try without spaces as fallback - marker = "{{range " + strings.TrimSpace(rangeExpr) + "}}" - start = strings.Index(r.lastContent, marker) - if start < 0 { - log("Could not find range start marker") - return "" - } - } - log("Found range start marker at position:", start) - log("Marker:", marker) - - contentStart := start + len(marker) - depth := 1 - endPos := -1 - - log("Starting content scan from position:", contentStart) - for i := contentStart; i < len(r.lastContent)-5; i++ { - if i+2 <= len(r.lastContent) && r.lastContent[i:i+2] == "{{" { - // Skip any whitespace after {{ - j := i + 2 - for j < len(r.lastContent) && unicode.IsSpace(rune(r.lastContent[j])) { - j++ - } - - // Check for range or end keywords - rest := r.lastContent[j:] - if strings.HasPrefix(rest, "range") { - log("Found nested range at position:", i) - depth++ - log("Depth increased to:", depth) - } else if strings.HasPrefix(rest, "end") { - log("Found end tag at position:", i) - depth-- - log("Depth decreased to:", depth) - if depth == 0 { - endPos = i - log("Found matching end tag at position:", endPos) - break - } - } - } - } - - if endPos == -1 { - log("No matching end tag found") - return "" - } - - content := r.lastContent[contentStart:endPos] - log("Extracted content:", content) - return content -} - -// Add a similar method for if blocks -func (r *Renderer) extractIfContent(ifExpr string) string { - marker := "{{ if " + strings.TrimSpace(ifExpr) + " }}" - start := strings.Index(r.lastContent, marker) - if start < 0 { - marker = "{{if " + strings.TrimSpace(ifExpr) + "}}" - start = strings.Index(r.lastContent, marker) - if start < 0 { - return "" - } - } - - contentStart := start + len(marker) - depth := 1 - endPos := -1 - elsePos := -1 - - for i := contentStart; i < len(r.lastContent)-5; i++ { - if i+2 <= len(r.lastContent) && r.lastContent[i:i+2] == "{{" { - rest := r.lastContent[i+2:] - if strings.HasPrefix(rest, " if ") || strings.HasPrefix(rest, "if ") { - depth++ - } else if strings.HasPrefix(rest, " end ") || strings.HasPrefix(rest, "end ") { - depth-- - if depth == 0 { - endPos = i - break - } - } else if depth == 1 && (strings.HasPrefix(rest, " else ") || strings.HasPrefix(rest, "else ")) { - elsePos = i - } - } - } - - if endPos == -1 { - return "" - } - - if elsePos != -1 { - return r.lastContent[contentStart:elsePos] +func trimQuotes(s string) string { + s = strings.TrimSpace(s) + if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { + s = s[1 : len(s)-1] } - return r.lastContent[contentStart:endPos] + return s }