From 1e598f6231ca1141e2d9c40ef19c974454730383 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Fri, 14 Jun 2024 14:33:19 +0900 Subject: [PATCH 01/37] fenced code block parser --- gnovm/pkg/doctest/parser.go | 60 ++++++++++++++++++++++ gnovm/pkg/doctest/parser_test.go | 85 ++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 gnovm/pkg/doctest/parser.go create mode 100644 gnovm/pkg/doctest/parser_test.go diff --git a/gnovm/pkg/doctest/parser.go b/gnovm/pkg/doctest/parser.go new file mode 100644 index 00000000000..427202db1fe --- /dev/null +++ b/gnovm/pkg/doctest/parser.go @@ -0,0 +1,60 @@ +package doctest + +import ( + "regexp" + "strings" +) + +type CodeBlock struct { + Content string + Start int + End int + T string + Index int +} + +func getCodeBlocks(body string) []CodeBlock { + var results []CodeBlock + + blocksRegex := regexp.MustCompile("```\\w*[^`]+```*") + matches := blocksRegex.FindAllStringIndex(body, -1) + + // initialize index to 0. will increment for each code block + index := 0 + + for _, match := range matches { + if len(match) < 2 { + continue + } + + codeStr := body[match[0]:match[1]] + // Remove the backticks from the code block content + codeStr = strings.TrimPrefix(codeStr, "```") + codeStr = strings.TrimSuffix(codeStr, "```") + result := CodeBlock{ + Content: codeStr, + Start: match[0], + End: match[1], + Index: index, // set the current index + } + + // extract the type (language) of the code block + lines := strings.Split(codeStr, "\n") + if len(lines) > 0 { + line1 := lines[0] + languageRegex := regexp.MustCompile(`^\w*`) + languageMatch := languageRegex.FindString(line1) + result.T = languageMatch + // Remove the language specifier from the code block content + result.Content = strings.TrimPrefix(result.Content, languageMatch) + result.Content = strings.TrimSpace(result.Content) + } + if result.T == "" { + result.T = "plain" + } + results = append(results, result) + index++ + } + + return results +} diff --git a/gnovm/pkg/doctest/parser_test.go b/gnovm/pkg/doctest/parser_test.go new file mode 100644 index 00000000000..8d3f286d749 --- /dev/null +++ b/gnovm/pkg/doctest/parser_test.go @@ -0,0 +1,85 @@ +package doctest + +import ( + "reflect" + "testing" +) + +func TestGetCodeBlocks(t *testing.T) { + tests := []struct { + name string + input string + expected []CodeBlock + }{ + { + name: "Single code block", + input: "```go\nfmt.Println(\"Hello, World!\")\n```", + expected: []CodeBlock{ + { + Content: "fmt.Println(\"Hello, World!\")", + Start: 0, + End: 38, + T: "go", + Index: 0, + }, + }, + }, + { + name: "Multiple code blocks", + input: "Here is some text.\n```python\ndef hello():\n print(\"Hello, World!\")\n```\nSome more text.\n```javascript\nconsole.log(\"Hello, World!\");\n```", + expected: []CodeBlock{ + { + Content: "def hello():\n print(\"Hello, World!\")", + Start: 19, + End: 72, + T: "python", + Index: 0, + }, + { + Content: "console.log(\"Hello, World!\");", + Start: 89, + End: 136, + T: "javascript", + Index: 1, + }, + }, + }, + { + name: "Code block with no language specifier", + input: "```\nfmt.Println(\"Hello, World!\")\n```", + expected: []CodeBlock{ + { + Content: "fmt.Println(\"Hello, World!\")", + Start: 0, + End: 36, + T: "plain", + Index: 0, + }, + }, + }, + { + name: "No code blocks", + input: "Just some text without any code blocks.", + expected: nil, + }, + { + name: "malformed code block", + input: "```go\nfmt.Println(\"Hello, World!\")", + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getCodeBlocks(tt.input) + if len(result) != len(tt.expected) { + t.Errorf("Failed %s: expected %d code blocks, got %d", tt.name, len(tt.expected), len(result)) + } + for i, res := range result { + if !reflect.DeepEqual(res, tt.expected[i]) { + t.Errorf("Failed %s: expected %v, got %v", tt.name, tt.expected[i], res) + } + } + }) + } +} From 168259d4462269d9c98f4a728682745a75fa9c5f Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Fri, 14 Jun 2024 15:52:00 +0900 Subject: [PATCH 02/37] file handler --- gnovm/pkg/doctest/parser.go | 119 ++++++++++++++++++++----------- gnovm/pkg/doctest/parser_test.go | 31 ++++++++ 2 files changed, 110 insertions(+), 40 deletions(-) diff --git a/gnovm/pkg/doctest/parser.go b/gnovm/pkg/doctest/parser.go index 427202db1fe..216dd73e694 100644 --- a/gnovm/pkg/doctest/parser.go +++ b/gnovm/pkg/doctest/parser.go @@ -1,6 +1,8 @@ package doctest import ( + "fmt" + "os" "regexp" "strings" ) @@ -13,48 +15,85 @@ type CodeBlock struct { Index int } -func getCodeBlocks(body string) []CodeBlock { - var results []CodeBlock +// ReadMarkdownFile reads a markdown file and returns its content +func ReadMarkdownFile(path string) (string, error) { + content, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("failed to read file: %w", err) + } + + return string(content), nil +} +// getCodeBlocks extracts code blocks from the markdown file content +func getCodeBlocks(body string) []CodeBlock { blocksRegex := regexp.MustCompile("```\\w*[^`]+```*") matches := blocksRegex.FindAllStringIndex(body, -1) - // initialize index to 0. will increment for each code block - index := 0 - - for _, match := range matches { - if len(match) < 2 { - continue - } - - codeStr := body[match[0]:match[1]] - // Remove the backticks from the code block content - codeStr = strings.TrimPrefix(codeStr, "```") - codeStr = strings.TrimSuffix(codeStr, "```") - result := CodeBlock{ - Content: codeStr, - Start: match[0], - End: match[1], - Index: index, // set the current index - } - - // extract the type (language) of the code block - lines := strings.Split(codeStr, "\n") - if len(lines) > 0 { - line1 := lines[0] - languageRegex := regexp.MustCompile(`^\w*`) - languageMatch := languageRegex.FindString(line1) - result.T = languageMatch - // Remove the language specifier from the code block content - result.Content = strings.TrimPrefix(result.Content, languageMatch) - result.Content = strings.TrimSpace(result.Content) - } - if result.T == "" { - result.T = "plain" - } - results = append(results, result) - index++ - } - - return results + return mapWithIndex(extractCodeBlock, matches, body) +} + +// extractCodeBlock extracts a single code block from the markdown content +func extractCodeBlock(match []int, index int, body string) CodeBlock { + if len(match) < 2 { + return CodeBlock{} + } + + codeStr := body[match[0]:match[1]] + // Remove the backticks from the code block content + codeStr = strings.TrimPrefix(codeStr, "```") + codeStr = strings.TrimSuffix(codeStr, "```") + + result := CodeBlock{ + Content: codeStr, + Start: match[0], + End: match[1], + Index: index, + } + + // extract the type (language) of the code block + lines := strings.Split(codeStr, "\n") + if len(lines) > 0 { + line1 := lines[0] + languageRegex := regexp.MustCompile(`^\w*`) + languageMatch := languageRegex.FindString(line1) + result.T = languageMatch + // Remove the language specifier from the code block content + result.Content = strings.TrimPrefix(result.Content, languageMatch) + result.Content = strings.TrimSpace(result.Content) + } + if result.T == "" { + result.T = "plain" + } + + return result +} + +// mapWithIndex applies a function to each element of a slice along with its index +func mapWithIndex[T, R any](f func(T, int, string) R, xs []T, body string) []R { + result := make([]R, len(xs)) + for i, x := range xs { + result[i] = f(x, i, body) + } + return result +} + +func WriteCodeBlockToFile(c CodeBlock) error { + if c.T == "go" { + c.T = "gno" + } + + fileName := fmt.Sprintf("%d.%s", c.Index, c.T) + file, err := os.Create(fileName) // TODO: use temp file + if err != nil { + return err + } + defer file.Close() + + _, err = file.WriteString(c.Content) + if err != nil { + return err + } + + return nil } diff --git a/gnovm/pkg/doctest/parser_test.go b/gnovm/pkg/doctest/parser_test.go index 8d3f286d749..de769265f84 100644 --- a/gnovm/pkg/doctest/parser_test.go +++ b/gnovm/pkg/doctest/parser_test.go @@ -1,6 +1,7 @@ package doctest import ( + "os" "reflect" "testing" ) @@ -83,3 +84,33 @@ func TestGetCodeBlocks(t *testing.T) { }) } } + +func TestWriteCodeBlockToFile(t *testing.T) { + cb := CodeBlock{ + Content: "package main\n\nfunc main() {\n\tprintln(\"Hello, World!\")\n}", + T: "go", + Index: 1, + } + + err := WriteCodeBlockToFile(cb) + if err != nil { + t.Errorf("writeCodeBlockToFile failed: %v", err) + } + + filename := "1.gno" + if _, err := os.Stat(filename); os.IsNotExist(err) { + t.Errorf("file %s not created", filename) + } + + content, err := os.ReadFile(filename) + if err != nil { + t.Errorf("failed to read file %s: %v", filename, err) + } + + expectedContent := cb.Content + if string(content) != expectedContent { + t.Errorf("file content mismatch\nexpected: %s\nactual: %s", expectedContent, string(content)) + } + + os.Remove(filename) +} From edb648eb38f57cbe64be3d14de7e47aeb4d6caa6 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Fri, 14 Jun 2024 18:16:31 +0900 Subject: [PATCH 03/37] pseudo code for execute a parsed code --- gnovm/pkg/doctest/exec.go | 29 +++++++++++++++++++++++++++++ gnovm/pkg/doctest/parser.go | 10 +++++++++- gnovm/pkg/doctest/parser_test.go | 2 +- 3 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 gnovm/pkg/doctest/exec.go diff --git a/gnovm/pkg/doctest/exec.go b/gnovm/pkg/doctest/exec.go new file mode 100644 index 00000000000..50b467f94bb --- /dev/null +++ b/gnovm/pkg/doctest/exec.go @@ -0,0 +1,29 @@ +package doctest + +import ( + "fmt" + + gno "github.com/gnolang/gno/gnovm/pkg/gnolang" +) + +var execResult map[string]string + +func executeCodeBlock(c CodeBlock) error { + if c.T != "go" || c.T != "gno" { + return fmt.Errorf("unsupported language: %s", c.T) + } + + m := gno.NewMachine("runMD", nil) + + // TODO: need to static analysis the code block + pkgContent := c.Content + parsedCode := gno.MustParseFile(fmt.Sprintf("%d.%s", c.Index, c.T), pkgContent) + + m.RunFiles(parsedCode) + m.RunMain() + res := m.PopValue().V.String() + + execResult[fmt.Sprintf("%d.%s", c.Index, c.T)] = res + + return nil +} diff --git a/gnovm/pkg/doctest/parser.go b/gnovm/pkg/doctest/parser.go index 216dd73e694..025bcfa7b0e 100644 --- a/gnovm/pkg/doctest/parser.go +++ b/gnovm/pkg/doctest/parser.go @@ -78,7 +78,9 @@ func mapWithIndex[T, R any](f func(T, int, string) R, xs []T, body string) []R { return result } -func WriteCodeBlockToFile(c CodeBlock) error { +// writeCodeBlockToFile writes a extracted code block to a temp file. +// This generated file will be executed by gnovm. +func writeCodeBlockToFile(c CodeBlock) error { if c.T == "go" { c.T = "gno" } @@ -97,3 +99,9 @@ func WriteCodeBlockToFile(c CodeBlock) error { return nil } + +// 어차피 파일의 소스코드를 인코딩해서 해쉬로 저장 할 것이기 때문에 실행 종료 후 파일이 삭제되도 별 문제 없을거라 생각. + +// 근데 실행 결과를 어떻게 가져오지? 출력 버퍼에 접근해서 가져와야 하나? + +// 정적 분석을 도입해 실행할 수 없는 코드는 미리 걸러내는 것이 좋을 듯? diff --git a/gnovm/pkg/doctest/parser_test.go b/gnovm/pkg/doctest/parser_test.go index de769265f84..84ea52d55a1 100644 --- a/gnovm/pkg/doctest/parser_test.go +++ b/gnovm/pkg/doctest/parser_test.go @@ -92,7 +92,7 @@ func TestWriteCodeBlockToFile(t *testing.T) { Index: 1, } - err := WriteCodeBlockToFile(cb) + err := writeCodeBlockToFile(cb) if err != nil { t.Errorf("writeCodeBlockToFile failed: %v", err) } From f60cb1c857f2746a172a2abdf92dc35098724048 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Fri, 14 Jun 2024 18:40:33 +0900 Subject: [PATCH 04/37] generate hash key based on the parsed code --- gnovm/pkg/doctest/exec.go | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/gnovm/pkg/doctest/exec.go b/gnovm/pkg/doctest/exec.go index 50b467f94bb..a98fee37049 100644 --- a/gnovm/pkg/doctest/exec.go +++ b/gnovm/pkg/doctest/exec.go @@ -1,16 +1,27 @@ package doctest import ( + "crypto/sha256" + "encoding/hex" "fmt" + "strings" gno "github.com/gnolang/gno/gnovm/pkg/gnolang" ) -var execResult map[string]string +// Global variable to store execution results +var codeResults map[string]string +func init() { + codeResults = make(map[string]string) +} + +// executeCodeBLock executes a code block using gnoVM and caching the result. func executeCodeBlock(c CodeBlock) error { - if c.T != "go" || c.T != "gno" { + if c.T != "go" && c.T != "gno" { return fmt.Errorf("unsupported language: %s", c.T) + } else { + c.T = "gno" } m := gno.NewMachine("runMD", nil) @@ -23,7 +34,17 @@ func executeCodeBlock(c CodeBlock) error { m.RunMain() res := m.PopValue().V.String() - execResult[fmt.Sprintf("%d.%s", c.Index, c.T)] = res + + // ignore the whitespace in the source code + key := generateCacheKey([]byte(strings.ReplaceAll(c.Content, " ", ""))) + codeResults[key] = res return nil } + +// generateCacheKey creates a SHA-256 hah of the source code to be used as a cache key +// to avoid re-executing the same code block. +func generateCacheKey(data []byte) string { + hash := sha256.Sum256(data) + return hex.EncodeToString(hash[:]) +} \ No newline at end of file From a577afc934062478fff084473b9a109755b147d1 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Fri, 14 Jun 2024 23:03:40 +0900 Subject: [PATCH 05/37] use tree-sitter for parsing markdown content --- gnovm/pkg/doctest/exec.go | 3 +- gnovm/pkg/doctest/parser.go | 134 +++++++++++++++++++------------ gnovm/pkg/doctest/parser_test.go | 45 ++++++++--- go.mod | 1 + go.sum | 5 ++ 5 files changed, 123 insertions(+), 65 deletions(-) diff --git a/gnovm/pkg/doctest/exec.go b/gnovm/pkg/doctest/exec.go index a98fee37049..a3df4876a8a 100644 --- a/gnovm/pkg/doctest/exec.go +++ b/gnovm/pkg/doctest/exec.go @@ -34,7 +34,6 @@ func executeCodeBlock(c CodeBlock) error { m.RunMain() res := m.PopValue().V.String() - // ignore the whitespace in the source code key := generateCacheKey([]byte(strings.ReplaceAll(c.Content, " ", ""))) codeResults[key] = res @@ -47,4 +46,4 @@ func executeCodeBlock(c CodeBlock) error { func generateCacheKey(data []byte) string { hash := sha256.Sum256(data) return hex.EncodeToString(hash[:]) -} \ No newline at end of file +} diff --git a/gnovm/pkg/doctest/parser.go b/gnovm/pkg/doctest/parser.go index 025bcfa7b0e..0f86f8acc61 100644 --- a/gnovm/pkg/doctest/parser.go +++ b/gnovm/pkg/doctest/parser.go @@ -1,18 +1,21 @@ package doctest import ( + "context" "fmt" "os" - "regexp" - "strings" + + sitter "github.com/smacker/go-tree-sitter" + markdown "github.com/smacker/go-tree-sitter/markdown/tree-sitter-markdown" ) +// CodeBlock represents a block of code extracted from the input text. type CodeBlock struct { - Content string - Start int - End int - T string - Index int + Content string // The content of the code block. + Start uint32 // The start byte position of the code block in the input text. + End uint32 // The end byte position of the code block in the input text. + T string // The language type of the code block. + Index int // The index of the code block in the sequence of extracted blocks. } // ReadMarkdownFile reads a markdown file and returns its content @@ -25,57 +28,94 @@ func ReadMarkdownFile(path string) (string, error) { return string(content), nil } -// getCodeBlocks extracts code blocks from the markdown file content +// getCodeBlocks extracts all code blocks from the provided markdown text. func getCodeBlocks(body string) []CodeBlock { - blocksRegex := regexp.MustCompile("```\\w*[^`]+```*") - matches := blocksRegex.FindAllStringIndex(body, -1) + parser := createParser() + tree, err := parseMarkdown(parser, body) + if err != nil { + fmt.Println("Error parsing:", err) + return nil + } + + return extractCodeBlocks(tree.RootNode(), body) +} + +// createParser creates and returns a new tree-sitter parser configured for Markdown. +func createParser() *sitter.Parser { + parser := sitter.NewParser() + parser.SetLanguage(markdown.GetLanguage()) + return parser +} - return mapWithIndex(extractCodeBlock, matches, body) +// parseMarkdown parses the input markdown text and returns the parse tree. +func parseMarkdown(parser *sitter.Parser, body string) (*sitter.Tree, error) { + ctx := context.Background() + return parser.ParseCtx(ctx, nil, []byte(body)) } -// extractCodeBlock extracts a single code block from the markdown content -func extractCodeBlock(match []int, index int, body string) CodeBlock { - if len(match) < 2 { - return CodeBlock{} +// extractCodeBlocks traverses the parse tree and extracts code blocks. +func extractCodeBlocks(rootNode *sitter.Node, body string) []CodeBlock { + codeBlocks := []CodeBlock{} + var index int + + var extract func(node *sitter.Node) + extract = func(node *sitter.Node) { + if node == nil { + return + } + + if node.Type() == "code_fence_content" { + codeBlock := createCodeBlock(node, body, index) + codeBlocks = append(codeBlocks, codeBlock) + index++ + } + + for i := 0; i < int(node.ChildCount()); i++ { + child := node.Child(i) + extract(child) + } } - codeStr := body[match[0]:match[1]] - // Remove the backticks from the code block content - codeStr = strings.TrimPrefix(codeStr, "```") - codeStr = strings.TrimSuffix(codeStr, "```") + extract(rootNode) + return codeBlocks +} + +// createCodeBlock creates a CodeBlock from a code fence content node. +func createCodeBlock(node *sitter.Node, body string, index int) CodeBlock { + startByte := node.StartByte() + endByte := node.EndByte() + content := body[startByte:endByte] + + language := detectLanguage(node, body) + content = removeTrailingBackticks(content) - result := CodeBlock{ - Content: codeStr, - Start: match[0], - End: match[1], + return CodeBlock{ + Content: content, + Start: startByte, + End: endByte, + T: language, Index: index, } +} - // extract the type (language) of the code block - lines := strings.Split(codeStr, "\n") - if len(lines) > 0 { - line1 := lines[0] - languageRegex := regexp.MustCompile(`^\w*`) - languageMatch := languageRegex.FindString(line1) - result.T = languageMatch - // Remove the language specifier from the code block content - result.Content = strings.TrimPrefix(result.Content, languageMatch) - result.Content = strings.TrimSpace(result.Content) - } - if result.T == "" { - result.T = "plain" +// detectLanguage detects the language of a code block from its parent node. +func detectLanguage(node *sitter.Node, body string) string { + codeFenceNode := node.Parent() + if codeFenceNode != nil && codeFenceNode.ChildCount() > 1 { + langNode := codeFenceNode.Child(1) + if langNode != nil && langNode.Type() == "info_string" { + return langNode.Content([]byte(body)) + } } - - return result + return "plain" } -// mapWithIndex applies a function to each element of a slice along with its index -func mapWithIndex[T, R any](f func(T, int, string) R, xs []T, body string) []R { - result := make([]R, len(xs)) - for i, x := range xs { - result[i] = f(x, i, body) +// removeTrailingBackticks removes trailing backticks from the code content. +func removeTrailingBackticks(content string) string { + if len(content) >= 3 && content[len(content)-3:] == "```" { + return content[:len(content)-3] } - return result + return content } // writeCodeBlockToFile writes a extracted code block to a temp file. @@ -99,9 +139,3 @@ func writeCodeBlockToFile(c CodeBlock) error { return nil } - -// 어차피 파일의 소스코드를 인코딩해서 해쉬로 저장 할 것이기 때문에 실행 종료 후 파일이 삭제되도 별 문제 없을거라 생각. - -// 근데 실행 결과를 어떻게 가져오지? 출력 버퍼에 접근해서 가져와야 하나? - -// 정적 분석을 도입해 실행할 수 없는 코드는 미리 걸러내는 것이 좋을 듯? diff --git a/gnovm/pkg/doctest/parser_test.go b/gnovm/pkg/doctest/parser_test.go index 84ea52d55a1..d29c7e022af 100644 --- a/gnovm/pkg/doctest/parser_test.go +++ b/gnovm/pkg/doctest/parser_test.go @@ -2,11 +2,12 @@ package doctest import ( "os" - "reflect" + "strings" "testing" ) func TestGetCodeBlocks(t *testing.T) { + t.Parallel() tests := []struct { name string input string @@ -18,7 +19,7 @@ func TestGetCodeBlocks(t *testing.T) { expected: []CodeBlock{ { Content: "fmt.Println(\"Hello, World!\")", - Start: 0, + Start: 6, End: 38, T: "go", Index: 0, @@ -31,14 +32,14 @@ func TestGetCodeBlocks(t *testing.T) { expected: []CodeBlock{ { Content: "def hello():\n print(\"Hello, World!\")", - Start: 19, - End: 72, + Start: 29, + End: 69, T: "python", Index: 0, }, { Content: "console.log(\"Hello, World!\");", - Start: 89, + Start: 103, End: 136, T: "javascript", Index: 1, @@ -51,7 +52,7 @@ func TestGetCodeBlocks(t *testing.T) { expected: []CodeBlock{ { Content: "fmt.Println(\"Hello, World!\")", - Start: 0, + Start: 4, End: 36, T: "plain", Index: 0, @@ -63,22 +64,35 @@ func TestGetCodeBlocks(t *testing.T) { input: "Just some text without any code blocks.", expected: nil, }, - { - name: "malformed code block", - input: "```go\nfmt.Println(\"Hello, World!\")", - expected: nil, - }, } for _, tt := range tests { + tt := tt t.Run(tt.name, func(t *testing.T) { result := getCodeBlocks(tt.input) if len(result) != len(tt.expected) { t.Errorf("Failed %s: expected %d code blocks, got %d", tt.name, len(tt.expected), len(result)) } + for i, res := range result { - if !reflect.DeepEqual(res, tt.expected[i]) { - t.Errorf("Failed %s: expected %v, got %v", tt.name, tt.expected[i], res) + if normalize(res.Content) != normalize(tt.expected[i].Content) { + t.Errorf("Failed %s: expected content %s, got %s", tt.name, tt.expected[i].Content, res.Content) + } + + if res.Start != tt.expected[i].Start { + t.Errorf("Failed %s: expected start %d, got %d", tt.name, tt.expected[i].Start, res.Start) + } + + if res.End != tt.expected[i].End { + t.Errorf("Failed %s: expected end %d, got %d", tt.name, tt.expected[i].End, res.End) + } + + if res.T != tt.expected[i].T { + t.Errorf("Failed %s: expected type %s, got %s", tt.name, tt.expected[i].T, res.T) + } + + if res.Index != tt.expected[i].Index { + t.Errorf("Failed %s: expected index %d, got %d", tt.name, tt.expected[i].Index, res.Index) } } }) @@ -86,6 +100,7 @@ func TestGetCodeBlocks(t *testing.T) { } func TestWriteCodeBlockToFile(t *testing.T) { + t.Parallel() cb := CodeBlock{ Content: "package main\n\nfunc main() {\n\tprintln(\"Hello, World!\")\n}", T: "go", @@ -114,3 +129,7 @@ func TestWriteCodeBlockToFile(t *testing.T) { os.Remove(filename) } + +func normalize(s string) string { + return strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(s, "\n", ""), "\r", ""), "\t", ""), " ", "") +} diff --git a/go.mod b/go.mod index 76c42f0419c..e037fc114d7 100644 --- a/go.mod +++ b/go.mod @@ -61,6 +61,7 @@ require ( github.com/nxadm/tail v1.4.11 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.4.3 // indirect + github.com/smacker/go-tree-sitter v0.0.0-20240614082054-0ac8d7d185ec // indirect github.com/zondax/hid v0.9.2 // indirect github.com/zondax/ledger-go v0.14.3 // indirect go.opentelemetry.io/otel/trace v1.25.0 // indirect diff --git a/go.sum b/go.sum index e4d728a106d..e3959f1d944 100644 --- a/go.sum +++ b/go.sum @@ -140,8 +140,13 @@ github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo= github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/smacker/go-tree-sitter v0.0.0-20240614082054-0ac8d7d185ec h1:MtVXE5PLx03zOAsEFUxXDZ6MEGU+jUdXKQ6Cz1Bz+JQ= +github.com/smacker/go-tree-sitter v0.0.0-20240614082054-0ac8d7d185ec/go.mod h1:q99oHDsbP0xRwmn7Vmob8gbSMNyvJ83OauXPSuHQuKE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.4/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= From b29530c67b83588b72143e198136bdfcdbf74f37 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Sat, 15 Jun 2024 15:37:27 +0900 Subject: [PATCH 06/37] capturing output from buffer --- gnovm/pkg/doctest/exec.go | 42 ++++---------- gnovm/pkg/doctest/exec_test.go | 29 ++++++++++ gnovm/pkg/doctest/parser.go | 97 ++++++++++++++++++++++++++------ gnovm/pkg/doctest/parser_test.go | 29 +++++++++- 4 files changed, 148 insertions(+), 49 deletions(-) create mode 100644 gnovm/pkg/doctest/exec_test.go diff --git a/gnovm/pkg/doctest/exec.go b/gnovm/pkg/doctest/exec.go index a3df4876a8a..bc21b2b40db 100644 --- a/gnovm/pkg/doctest/exec.go +++ b/gnovm/pkg/doctest/exec.go @@ -1,49 +1,31 @@ package doctest import ( - "crypto/sha256" - "encoding/hex" + "bytes" "fmt" - "strings" + _ "strings" gno "github.com/gnolang/gno/gnovm/pkg/gnolang" ) -// Global variable to store execution results -var codeResults map[string]string - -func init() { - codeResults = make(map[string]string) -} - // executeCodeBLock executes a code block using gnoVM and caching the result. -func executeCodeBlock(c CodeBlock) error { - if c.T != "go" && c.T != "gno" { - return fmt.Errorf("unsupported language: %s", c.T) - } else { - c.T = "gno" +func executeCodeBlock(c CodeBlock) (string, error) { + if c.T != "go" { + return "", fmt.Errorf("unsupported language: %s", c.T) } - m := gno.NewMachine("runMD", nil) + m := gno.NewMachine("main", nil) + + // capture output + var output bytes.Buffer + m.Output = &output - // TODO: need to static analysis the code block pkgContent := c.Content parsedCode := gno.MustParseFile(fmt.Sprintf("%d.%s", c.Index, c.T), pkgContent) m.RunFiles(parsedCode) m.RunMain() - res := m.PopValue().V.String() - - // ignore the whitespace in the source code - key := generateCacheKey([]byte(strings.ReplaceAll(c.Content, " ", ""))) - codeResults[key] = res - - return nil -} -// generateCacheKey creates a SHA-256 hah of the source code to be used as a cache key -// to avoid re-executing the same code block. -func generateCacheKey(data []byte) string { - hash := sha256.Sum256(data) - return hex.EncodeToString(hash[:]) + result := output.String() + return result, nil } diff --git a/gnovm/pkg/doctest/exec_test.go b/gnovm/pkg/doctest/exec_test.go new file mode 100644 index 00000000000..d1e4c01dff1 --- /dev/null +++ b/gnovm/pkg/doctest/exec_test.go @@ -0,0 +1,29 @@ +package doctest + +import ( + "testing" +) + +func TestExecuteCodeBlock(t *testing.T) { + codeBlock := CodeBlock{ + Content: "package main\n\nfunc main() { println(\"Hello, World!\") }", + Start: 0, + End: 50, + T: "go", + Index: 0, + } + + err := writeCodeBlockToFile(codeBlock) + if err != nil { + t.Errorf("Failed to write code block to file: %v", err) + } + + res, err := executeCodeBlock(codeBlock) + if err != nil { + t.Errorf("Failed to execute code block: %v", err) + } + + if res != "Hello, World!\n" { + t.Errorf("Expected 'Hello, World!', got %s", res) + } +} diff --git a/gnovm/pkg/doctest/parser.go b/gnovm/pkg/doctest/parser.go index 0f86f8acc61..dffb6c63488 100644 --- a/gnovm/pkg/doctest/parser.go +++ b/gnovm/pkg/doctest/parser.go @@ -4,11 +4,28 @@ import ( "context" "fmt" "os" + "strings" sitter "github.com/smacker/go-tree-sitter" markdown "github.com/smacker/go-tree-sitter/markdown/tree-sitter-markdown" ) +// tree-sitter node types for markdown code blocks. +// https://github.com/smacker/go-tree-sitter/blob/0ac8d7d185ec65349d3d9e6a7a493b81ae05d198/markdown/tree-sitter-markdown/scanner.c#L9-L88 +const ( + FENCED_CODE_BLOCK = "fenced_code_block" + CODE_FENCE_CONTENT = "code_fence_content" + CODE_FENCE_END = "code_fence_end" + CODE_FENCE_END_BACKTICKS = "code_fence_end_backticks" + INFO_STRING = "info_string" +) + +// Code block markers. +const ( + Backticks = "```" + Tildes = "~~~" +) + // CodeBlock represents a block of code extracted from the input text. type CodeBlock struct { Content string // The content of the code block. @@ -53,30 +70,26 @@ func parseMarkdown(parser *sitter.Parser, body string) (*sitter.Tree, error) { return parser.ParseCtx(ctx, nil, []byte(body)) } -// extractCodeBlocks traverses the parse tree and extracts code blocks. +// extractCodeBlocks traverses the parse tree and extracts code blocks using tree-sitter. +// It takes the root node of the parse tree and the complete body string as input. func extractCodeBlocks(rootNode *sitter.Node, body string) []CodeBlock { - codeBlocks := []CodeBlock{} - var index int - - var extract func(node *sitter.Node) - extract = func(node *sitter.Node) { - if node == nil { - return - } + codeBlocks := make([]CodeBlock, 0) - if node.Type() == "code_fence_content" { - codeBlock := createCodeBlock(node, body, index) + // define a recursive function to traverse the parse tree + var traverse func(node *sitter.Node) + traverse = func(node *sitter.Node) { + if node.Type() == CODE_FENCE_CONTENT { + codeBlock := createCodeBlock(node, body, len(codeBlocks)) codeBlocks = append(codeBlocks, codeBlock) - index++ } for i := 0; i < int(node.ChildCount()); i++ { child := node.Child(i) - extract(child) + traverse(child) } } - extract(rootNode) + traverse(rootNode) return codeBlocks } @@ -87,7 +100,7 @@ func createCodeBlock(node *sitter.Node, body string, index int) CodeBlock { content := body[startByte:endByte] language := detectLanguage(node, body) - content = removeTrailingBackticks(content) + startByte, endByte, content = adjustContentBoundaries(node, startByte, endByte, content, body) return CodeBlock{ Content: content, @@ -103,21 +116,69 @@ func detectLanguage(node *sitter.Node, body string) string { codeFenceNode := node.Parent() if codeFenceNode != nil && codeFenceNode.ChildCount() > 1 { langNode := codeFenceNode.Child(1) - if langNode != nil && langNode.Type() == "info_string" { + if langNode != nil && langNode.Type() == INFO_STRING { return langNode.Content([]byte(body)) } } + + // default to plain text if no language is specified return "plain" } // removeTrailingBackticks removes trailing backticks from the code content. func removeTrailingBackticks(content string) string { - if len(content) >= 3 && content[len(content)-3:] == "```" { - return content[:len(content)-3] + // https://www.markdownguide.org/extended-syntax/#fenced-code-blocks + // a code block can have a closing fence with three or more backticks or tildes. + content = strings.TrimRight(content, "`~") + if len(content) >= 3 { + blockSuffix := content[len(content)-3:] + switch blockSuffix { + case Backticks, Tildes: + return content[:len(content)-3] + default: + return content + } } return content } +// adjustContentBoundaries adjusts the content boundaries of a code block node. +// The function checks the parent node type and adjusts the end byte position if it is a fenced code block. +func adjustContentBoundaries(node *sitter.Node, startByte, endByte uint32, content, body string) (uint32, uint32, string) { + parentNode := node.Parent() + if parentNode == nil { + return startByte, endByte, removeTrailingBackticks(content) + } + + // adjust the end byte based on the parent node type + if parentNode.Type() == FENCED_CODE_BLOCK { + // find the end marker node + endMarkerNode := findEndMarkerNode(parentNode) + if endMarkerNode != nil { + endByte = endMarkerNode.StartByte() + content = body[startByte:endByte] + } + } + + return startByte, endByte, removeTrailingBackticks(content) +} + +// findEndMarkerNode finds the end marker node of a fenced code block using tree-sitter. +// It takes the parent node of the code block as input and iterates through its child nodes. +func findEndMarkerNode(parentNode *sitter.Node) *sitter.Node { + for i := 0; i < int(parentNode.ChildCount()); i++ { + child := parentNode.Child(i) + switch child.Type() { + case CODE_FENCE_END, CODE_FENCE_END_BACKTICKS: + return child + default: + continue + } + } + + return nil +} + // writeCodeBlockToFile writes a extracted code block to a temp file. // This generated file will be executed by gnovm. func writeCodeBlockToFile(c CodeBlock) error { diff --git a/gnovm/pkg/doctest/parser_test.go b/gnovm/pkg/doctest/parser_test.go index d29c7e022af..93dc6668047 100644 --- a/gnovm/pkg/doctest/parser_test.go +++ b/gnovm/pkg/doctest/parser_test.go @@ -14,7 +14,7 @@ func TestGetCodeBlocks(t *testing.T) { expected []CodeBlock }{ { - name: "Single code block", + name: "Single code block with backticks", input: "```go\nfmt.Println(\"Hello, World!\")\n```", expected: []CodeBlock{ { @@ -26,6 +26,32 @@ func TestGetCodeBlocks(t *testing.T) { }, }, }, + { + name: "Single code block with additional backticks", + input: "```go\nfmt.Println(\"Hello, World!\")\n``````", + expected: []CodeBlock{ + { + Content: "fmt.Println(\"Hello, World!\")", + Start: 6, + End: 41, // TODO: should be 38 + T: "go", + Index: 0, + }, + }, + }, + { + name: "Single code block with tildes", + input: "~~~go\nfmt.Println(\"Hello, World!\")\n~~~", + expected: []CodeBlock{ + { + Content: "fmt.Println(\"Hello, World!\")", + Start: 6, + End: 38, + T: "go", + Index: 0, + }, + }, + }, { name: "Multiple code blocks", input: "Here is some text.\n```python\ndef hello():\n print(\"Hello, World!\")\n```\nSome more text.\n```javascript\nconsole.log(\"Hello, World!\");\n```", @@ -130,6 +156,7 @@ func TestWriteCodeBlockToFile(t *testing.T) { os.Remove(filename) } +// ignore whitespace in the source code func normalize(s string) string { return strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(s, "\n", ""), "\r", ""), "\t", ""), " ", "") } From 643086201cfdcaf379c5f295ac7d75ac2e13a00a Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Sat, 15 Jun 2024 15:42:41 +0900 Subject: [PATCH 07/37] refactor --- gnovm/pkg/doctest/parser.go | 92 ++++++++++++++++++------------------- 1 file changed, 45 insertions(+), 47 deletions(-) diff --git a/gnovm/pkg/doctest/parser.go b/gnovm/pkg/doctest/parser.go index dffb6c63488..cf013a3a7b9 100644 --- a/gnovm/pkg/doctest/parser.go +++ b/gnovm/pkg/doctest/parser.go @@ -75,22 +75,22 @@ func parseMarkdown(parser *sitter.Parser, body string) (*sitter.Tree, error) { func extractCodeBlocks(rootNode *sitter.Node, body string) []CodeBlock { codeBlocks := make([]CodeBlock, 0) - // define a recursive function to traverse the parse tree - var traverse func(node *sitter.Node) - traverse = func(node *sitter.Node) { - if node.Type() == CODE_FENCE_CONTENT { - codeBlock := createCodeBlock(node, body, len(codeBlocks)) - codeBlocks = append(codeBlocks, codeBlock) - } - - for i := 0; i < int(node.ChildCount()); i++ { - child := node.Child(i) - traverse(child) - } - } - - traverse(rootNode) - return codeBlocks + // define a recursive function to traverse the parse tree + var traverse func(node *sitter.Node) + traverse = func(node *sitter.Node) { + if node.Type() == CODE_FENCE_CONTENT { + codeBlock := createCodeBlock(node, body, len(codeBlocks)) + codeBlocks = append(codeBlocks, codeBlock) + } + + for i := 0; i < int(node.ChildCount()); i++ { + child := node.Child(i) + traverse(child) + } + } + + traverse(rootNode) + return codeBlocks } // createCodeBlock creates a CodeBlock from a code fence content node. @@ -129,52 +129,52 @@ func detectLanguage(node *sitter.Node, body string) string { func removeTrailingBackticks(content string) string { // https://www.markdownguide.org/extended-syntax/#fenced-code-blocks // a code block can have a closing fence with three or more backticks or tildes. - content = strings.TrimRight(content, "`~") - if len(content) >= 3 { + content = strings.TrimRight(content, "`~") + if len(content) >= 3 { blockSuffix := content[len(content)-3:] switch blockSuffix { - case Backticks, Tildes: - return content[:len(content)-3] - default: - return content - } - } - return content + case Backticks, Tildes: + return content[:len(content)-3] + default: + return content + } + } + return content } // adjustContentBoundaries adjusts the content boundaries of a code block node. // The function checks the parent node type and adjusts the end byte position if it is a fenced code block. func adjustContentBoundaries(node *sitter.Node, startByte, endByte uint32, content, body string) (uint32, uint32, string) { - parentNode := node.Parent() - if parentNode == nil { - return startByte, endByte, removeTrailingBackticks(content) - } - - // adjust the end byte based on the parent node type - if parentNode.Type() == FENCED_CODE_BLOCK { - // find the end marker node - endMarkerNode := findEndMarkerNode(parentNode) - if endMarkerNode != nil { - endByte = endMarkerNode.StartByte() - content = body[startByte:endByte] - } - } - - return startByte, endByte, removeTrailingBackticks(content) + parentNode := node.Parent() + if parentNode == nil { + return startByte, endByte, removeTrailingBackticks(content) + } + + // adjust the end byte based on the parent node type + if parentNode.Type() == FENCED_CODE_BLOCK { + // find the end marker node + endMarkerNode := findEndMarkerNode(parentNode) + if endMarkerNode != nil { + endByte = endMarkerNode.StartByte() + content = body[startByte:endByte] + } + } + + return startByte, endByte, removeTrailingBackticks(content) } // findEndMarkerNode finds the end marker node of a fenced code block using tree-sitter. // It takes the parent node of the code block as input and iterates through its child nodes. func findEndMarkerNode(parentNode *sitter.Node) *sitter.Node { - for i := 0; i < int(parentNode.ChildCount()); i++ { - child := parentNode.Child(i) + for i := 0; i < int(parentNode.ChildCount()); i++ { + child := parentNode.Child(i) switch child.Type() { case CODE_FENCE_END, CODE_FENCE_END_BACKTICKS: return child default: continue } - } + } return nil } @@ -182,9 +182,7 @@ func findEndMarkerNode(parentNode *sitter.Node) *sitter.Node { // writeCodeBlockToFile writes a extracted code block to a temp file. // This generated file will be executed by gnovm. func writeCodeBlockToFile(c CodeBlock) error { - if c.T == "go" { - c.T = "gno" - } + if c.T == "go" { c.T = "gno" } fileName := fmt.Sprintf("%d.%s", c.Index, c.T) file, err := os.Create(fileName) // TODO: use temp file From b7ef8adb1783e567bdcf79b3a119da1eaaa5f9fe Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Sun, 16 Jun 2024 12:00:41 +0900 Subject: [PATCH 08/37] save --- gnovm/pkg/doctest/exec.go | 3 +- gnovm/pkg/doctest/exec_test.go | 111 ++++++++++++++++++++++++++---- gnovm/pkg/doctest/parser.go | 106 ++++++++++++---------------- gnovm/pkg/doctest/parser_test.go | 32 --------- gnovm/pkg/gnolang/machine_test.go | 2 +- 5 files changed, 143 insertions(+), 111 deletions(-) diff --git a/gnovm/pkg/doctest/exec.go b/gnovm/pkg/doctest/exec.go index bc21b2b40db..6e3c3e7f7f7 100644 --- a/gnovm/pkg/doctest/exec.go +++ b/gnovm/pkg/doctest/exec.go @@ -3,7 +3,6 @@ package doctest import ( "bytes" "fmt" - _ "strings" gno "github.com/gnolang/gno/gnovm/pkg/gnolang" ) @@ -21,6 +20,8 @@ func executeCodeBlock(c CodeBlock) (string, error) { m.Output = &output pkgContent := c.Content + + // throw panic when parsing fails parsedCode := gno.MustParseFile(fmt.Sprintf("%d.%s", c.Index, c.T), pkgContent) m.RunFiles(parsedCode) diff --git a/gnovm/pkg/doctest/exec_test.go b/gnovm/pkg/doctest/exec_test.go index d1e4c01dff1..cb59ee14725 100644 --- a/gnovm/pkg/doctest/exec_test.go +++ b/gnovm/pkg/doctest/exec_test.go @@ -5,25 +5,108 @@ import ( ) func TestExecuteCodeBlock(t *testing.T) { - codeBlock := CodeBlock{ - Content: "package main\n\nfunc main() { println(\"Hello, World!\") }", - Start: 0, - End: 50, - T: "go", - Index: 0, + tests := []struct { + name string + codeBlock CodeBlock + expected string + isErr bool + }{ + { + name: "Hello, World!", + codeBlock: CodeBlock{ + Content: ` +package main + +func main() { + println("Hello, World!") +}`, + T: "go", + }, + expected: "Hello, World!\n", + }, + { + name: "Multiple prints", + codeBlock: CodeBlock{ + Content: ` +package main + +func main() { + println("Hello"); + println("World") +}`, + T: "go", + }, + expected: "Hello\nWorld\n", + }, + { + name: "Print variables", + codeBlock: CodeBlock{ + Content: ` +package main + +func main() { + a := 10 + b := 20 + println(a + b) +}`, + T: "go", + }, + expected: "30\n", + }, + { + name: "unsupported language", + codeBlock: CodeBlock{ + Content: ` +data Tree a = Empty | Node a (Tree a) (Tree a) + deriving (Eq, Show) + +data Direction = LH | RH + deriving (Eq, Show) + +splay :: (Ord a) => a -> Tree a -> Tree a +splay a t = rebuild $ path a t [(undefined,t)]`, + T: "haskell", + }, + isErr: true, + }, } - err := writeCodeBlockToFile(codeBlock) - if err != nil { - t.Errorf("Failed to write code block to file: %v", err) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + res, err := executeCodeBlock(tc.codeBlock) + if tc.isErr && err == nil { + t.Errorf("%s did not return an error", tc.name) + } + + if res != tc.expected { + t.Errorf("%s = %v, want %v", tc.name, res, tc.expected) + } + }) } +} - res, err := executeCodeBlock(codeBlock) - if err != nil { - t.Errorf("Failed to execute code block: %v", err) +func TestExecuteCodeBlock_ShouldPanic(t *testing.T) { + tests := []struct { + name string + codeBlock CodeBlock + }{ + { + name: "syntax error", + codeBlock: CodeBlock{ + Content: "package main\n\nfunc main() { println(\"Hello, World!\")", + T: "go", + }, + }, } - if res != "Hello, World!\n" { - t.Errorf("Expected 'Hello, World!', got %s", res) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Errorf("%s did not panic", tc.name) + } + }() + _, _ = executeCodeBlock(tc.codeBlock) + }) } } diff --git a/gnovm/pkg/doctest/parser.go b/gnovm/pkg/doctest/parser.go index cf013a3a7b9..817cbaa62d3 100644 --- a/gnovm/pkg/doctest/parser.go +++ b/gnovm/pkg/doctest/parser.go @@ -75,22 +75,22 @@ func parseMarkdown(parser *sitter.Parser, body string) (*sitter.Tree, error) { func extractCodeBlocks(rootNode *sitter.Node, body string) []CodeBlock { codeBlocks := make([]CodeBlock, 0) - // define a recursive function to traverse the parse tree - var traverse func(node *sitter.Node) - traverse = func(node *sitter.Node) { - if node.Type() == CODE_FENCE_CONTENT { - codeBlock := createCodeBlock(node, body, len(codeBlocks)) - codeBlocks = append(codeBlocks, codeBlock) - } - - for i := 0; i < int(node.ChildCount()); i++ { - child := node.Child(i) - traverse(child) - } - } - - traverse(rootNode) - return codeBlocks + // define a recursive function to traverse the parse tree + var traverse func(node *sitter.Node) + traverse = func(node *sitter.Node) { + if node.Type() == CODE_FENCE_CONTENT { + codeBlock := createCodeBlock(node, body, len(codeBlocks)) + codeBlocks = append(codeBlocks, codeBlock) + } + + for i := 0; i < int(node.ChildCount()); i++ { + child := node.Child(i) + traverse(child) + } + } + + traverse(rootNode) + return codeBlocks } // createCodeBlock creates a CodeBlock from a code fence content node. @@ -129,71 +129,51 @@ func detectLanguage(node *sitter.Node, body string) string { func removeTrailingBackticks(content string) string { // https://www.markdownguide.org/extended-syntax/#fenced-code-blocks // a code block can have a closing fence with three or more backticks or tildes. - content = strings.TrimRight(content, "`~") - if len(content) >= 3 { + content = strings.TrimRight(content, "`~") + if len(content) >= 3 { blockSuffix := content[len(content)-3:] switch blockSuffix { - case Backticks, Tildes: - return content[:len(content)-3] - default: - return content - } - } - return content + case Backticks, Tildes: + return content[:len(content)-3] + default: + return content + } + } + return content } // adjustContentBoundaries adjusts the content boundaries of a code block node. // The function checks the parent node type and adjusts the end byte position if it is a fenced code block. func adjustContentBoundaries(node *sitter.Node, startByte, endByte uint32, content, body string) (uint32, uint32, string) { - parentNode := node.Parent() - if parentNode == nil { - return startByte, endByte, removeTrailingBackticks(content) - } - - // adjust the end byte based on the parent node type - if parentNode.Type() == FENCED_CODE_BLOCK { - // find the end marker node - endMarkerNode := findEndMarkerNode(parentNode) - if endMarkerNode != nil { - endByte = endMarkerNode.StartByte() - content = body[startByte:endByte] - } - } - - return startByte, endByte, removeTrailingBackticks(content) + parentNode := node.Parent() + if parentNode == nil { + return startByte, endByte, removeTrailingBackticks(content) + } + + // adjust the end byte based on the parent node type + if parentNode.Type() == FENCED_CODE_BLOCK { + // find the end marker node + endMarkerNode := findEndMarkerNode(parentNode) + if endMarkerNode != nil { + endByte = endMarkerNode.StartByte() + content = body[startByte:endByte] + } + } + + return startByte, endByte, removeTrailingBackticks(content) } // findEndMarkerNode finds the end marker node of a fenced code block using tree-sitter. // It takes the parent node of the code block as input and iterates through its child nodes. func findEndMarkerNode(parentNode *sitter.Node) *sitter.Node { - for i := 0; i < int(parentNode.ChildCount()); i++ { - child := parentNode.Child(i) + for i := 0; i < int(parentNode.ChildCount()); i++ { + child := parentNode.Child(i) switch child.Type() { case CODE_FENCE_END, CODE_FENCE_END_BACKTICKS: return child default: continue } - } - - return nil -} - -// writeCodeBlockToFile writes a extracted code block to a temp file. -// This generated file will be executed by gnovm. -func writeCodeBlockToFile(c CodeBlock) error { - if c.T == "go" { c.T = "gno" } - - fileName := fmt.Sprintf("%d.%s", c.Index, c.T) - file, err := os.Create(fileName) // TODO: use temp file - if err != nil { - return err - } - defer file.Close() - - _, err = file.WriteString(c.Content) - if err != nil { - return err } return nil diff --git a/gnovm/pkg/doctest/parser_test.go b/gnovm/pkg/doctest/parser_test.go index 93dc6668047..35a12154400 100644 --- a/gnovm/pkg/doctest/parser_test.go +++ b/gnovm/pkg/doctest/parser_test.go @@ -1,7 +1,6 @@ package doctest import ( - "os" "strings" "testing" ) @@ -125,37 +124,6 @@ func TestGetCodeBlocks(t *testing.T) { } } -func TestWriteCodeBlockToFile(t *testing.T) { - t.Parallel() - cb := CodeBlock{ - Content: "package main\n\nfunc main() {\n\tprintln(\"Hello, World!\")\n}", - T: "go", - Index: 1, - } - - err := writeCodeBlockToFile(cb) - if err != nil { - t.Errorf("writeCodeBlockToFile failed: %v", err) - } - - filename := "1.gno" - if _, err := os.Stat(filename); os.IsNotExist(err) { - t.Errorf("file %s not created", filename) - } - - content, err := os.ReadFile(filename) - if err != nil { - t.Errorf("failed to read file %s: %v", filename, err) - } - - expectedContent := cb.Content - if string(content) != expectedContent { - t.Errorf("file content mismatch\nexpected: %s\nactual: %s", expectedContent, string(content)) - } - - os.Remove(filename) -} - // ignore whitespace in the source code func normalize(s string) string { return strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(s, "\n", ""), "\r", ""), "\t", ""), " ", "") diff --git a/gnovm/pkg/gnolang/machine_test.go b/gnovm/pkg/gnolang/machine_test.go index 8e27b127fbb..7d43724c48b 100644 --- a/gnovm/pkg/gnolang/machine_test.go +++ b/gnovm/pkg/gnolang/machine_test.go @@ -26,7 +26,7 @@ func TestRunMemPackageWithOverrides_revertToOld(t *testing.T) { baseStore := dbadapter.StoreConstructor(db, stypes.StoreOptions{}) iavlStore := iavl.StoreConstructor(db, stypes.StoreOptions{}) store := NewStore(nil, baseStore, iavlStore) - m := NewMachine("std", store) + m := NewMachine("main", store) m.RunMemPackageWithOverrides(&std.MemPackage{ Name: "std", Path: "std", From b649727c886f2e76a0b72f460dbf980da2c59db7 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Sun, 16 Jun 2024 18:16:00 +0900 Subject: [PATCH 09/37] change to use MemDB and add execute options --- gnovm/pkg/doctest/exec.go | 52 +++++++++++++++---- gnovm/pkg/doctest/exec_test.go | 92 ++++++++++++++++++++++++++++++++-- gnovm/pkg/doctest/io.go | 16 ++++++ gnovm/pkg/doctest/parser.go | 86 ++++++++++++++++++++++++------- 4 files changed, 211 insertions(+), 35 deletions(-) create mode 100644 gnovm/pkg/doctest/io.go diff --git a/gnovm/pkg/doctest/exec.go b/gnovm/pkg/doctest/exec.go index 6e3c3e7f7f7..5a2ea99c02b 100644 --- a/gnovm/pkg/doctest/exec.go +++ b/gnovm/pkg/doctest/exec.go @@ -5,28 +5,58 @@ import ( "fmt" gno "github.com/gnolang/gno/gnovm/pkg/gnolang" + "github.com/gnolang/gno/tm2/pkg/db/memdb" + "github.com/gnolang/gno/tm2/pkg/std" + "github.com/gnolang/gno/tm2/pkg/store/dbadapter" + "github.com/gnolang/gno/tm2/pkg/store/iavl" + stypes "github.com/gnolang/gno/tm2/pkg/store/types" ) -// executeCodeBLock executes a code block using gnoVM and caching the result. -func executeCodeBlock(c CodeBlock) (string, error) { - if c.T != "go" { - return "", fmt.Errorf("unsupported language: %s", c.T) +const ( + IGNORE = "ignore" + SHOULD_PANIC = "should_panic" + NO_RUN = "no_run" +) + +func ExecuteCodeBlock(c CodeBlock) (string, error) { + if c.ContainsOptions(IGNORE) { + return "", nil } - m := gno.NewMachine("main", nil) + if c.T == "go" { + c.T = "gno" + } else if c.T != "gno" { + return "", fmt.Errorf("unsupported language: %s", c.T) + } - // capture output + db := memdb.NewMemDB() + baseStore := dbadapter.StoreConstructor(db, stypes.StoreOptions{}) + iavlStore := iavl.StoreConstructor(db, stypes.StoreOptions{}) + store := gno.NewStore(nil, baseStore, iavlStore) + + m := gno.NewMachine("main", store) + m.RunMemPackageWithOverrides(&std.MemPackage{ + Name: c.Package, + Path: c.Package, + Files: []*std.MemFile{ + {Name: fmt.Sprintf("%d.%s", c.Index, c.T), Body: c.Content}, + }, + }, true) + + // Capture output var output bytes.Buffer m.Output = &output - pkgContent := c.Content - - // throw panic when parsing fails - parsedCode := gno.MustParseFile(fmt.Sprintf("%d.%s", c.Index, c.T), pkgContent) + if c.ContainsOptions(NO_RUN) { + return "", nil + } - m.RunFiles(parsedCode) m.RunMain() result := output.String() + if c.ContainsOptions(SHOULD_PANIC) { + return "", fmt.Errorf("expected panic, got %q", result) + } + return result, nil } diff --git a/gnovm/pkg/doctest/exec_test.go b/gnovm/pkg/doctest/exec_test.go index cb59ee14725..584fb148826 100644 --- a/gnovm/pkg/doctest/exec_test.go +++ b/gnovm/pkg/doctest/exec_test.go @@ -1,6 +1,7 @@ package doctest import ( + "reflect" "testing" ) @@ -20,7 +21,8 @@ package main func main() { println("Hello, World!") }`, - T: "go", + T: "go", + Package: "main", }, expected: "Hello, World!\n", }, @@ -34,7 +36,8 @@ func main() { println("Hello"); println("World") }`, - T: "go", + T: "go", + Package: "main", }, expected: "Hello\nWorld\n", }, @@ -49,7 +52,8 @@ func main() { b := 20 println(a + b) }`, - T: "go", + T: "go", + Package: "main", }, expected: "30\n", }, @@ -73,7 +77,8 @@ splay a t = rebuild $ path a t [(undefined,t)]`, for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - res, err := executeCodeBlock(tc.codeBlock) + // res, err := executeCodeBlock(tc.codeBlock) + res, err := ExecuteCodeBlock(tc.codeBlock) if tc.isErr && err == nil { t.Errorf("%s did not return an error", tc.name) } @@ -81,6 +86,14 @@ splay a t = rebuild $ path a t [(undefined,t)]`, if res != tc.expected { t.Errorf("%s = %v, want %v", tc.name, res, tc.expected) } + + if tc.codeBlock.T == "go" { + if tc.codeBlock.Package != "" { + if tc.codeBlock.Package != "main" { + t.Errorf("%s = %v, want %v", tc.name, tc.codeBlock.Package, "main") + } + } + } }) } } @@ -106,7 +119,76 @@ func TestExecuteCodeBlock_ShouldPanic(t *testing.T) { t.Errorf("%s did not panic", tc.name) } }() - _, _ = executeCodeBlock(tc.codeBlock) + _, _ = ExecuteCodeBlock(tc.codeBlock) + }) + } +} + +func TestExtractOptions(t *testing.T) { + tests := []struct { + name string + input string + expected []string + }{ + { + name: "Ignore option", + input: ` +//gno: ignore +package main + +func main() { + println("This code should be ignored") +} +`, + expected: []string{"ignore"}, + }, + { + name: "No run option", + input: ` +//gno: no_run +package main + +func main() { + println("This code should not run") +} +`, + expected: []string{"no_run"}, + }, + { + name: "Should panic option", + input: ` +//gno: should_panic +package main + +func main() { + panic("Expected panic") +} +`, + expected: []string{"should_panic"}, + }, + { + name: "No options", + input: ` +package main + +func main() { + println("No options") +} +`, + expected: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + codeBlock := CodeBlock{ + Content: tt.input, + T: "go", + } + options := extractOptions(codeBlock.Content) + if !reflect.DeepEqual(options, tt.expected) { + t.Errorf("got %v, want %v", options, tt.expected) + } }) } } diff --git a/gnovm/pkg/doctest/io.go b/gnovm/pkg/doctest/io.go new file mode 100644 index 00000000000..985a01e8154 --- /dev/null +++ b/gnovm/pkg/doctest/io.go @@ -0,0 +1,16 @@ +package doctest + +import ( + "fmt" + "os" +) + +// ReadMarkdownFile reads a markdown file and returns its content +func ReadMarkdownFile(path string) (string, error) { + content, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("failed to read file: %w", err) + } + + return string(content), nil +} diff --git a/gnovm/pkg/doctest/parser.go b/gnovm/pkg/doctest/parser.go index 817cbaa62d3..b775fb95697 100644 --- a/gnovm/pkg/doctest/parser.go +++ b/gnovm/pkg/doctest/parser.go @@ -3,7 +3,8 @@ package doctest import ( "context" "fmt" - "os" + "go/parser" + "go/token" "strings" sitter "github.com/smacker/go-tree-sitter" @@ -28,21 +29,13 @@ const ( // CodeBlock represents a block of code extracted from the input text. type CodeBlock struct { - Content string // The content of the code block. - Start uint32 // The start byte position of the code block in the input text. - End uint32 // The end byte position of the code block in the input text. - T string // The language type of the code block. - Index int // The index of the code block in the sequence of extracted blocks. -} - -// ReadMarkdownFile reads a markdown file and returns its content -func ReadMarkdownFile(path string) (string, error) { - content, err := os.ReadFile(path) - if err != nil { - return "", fmt.Errorf("failed to read file: %w", err) - } - - return string(content), nil + Content string // The content of the code block. + Start uint32 // The start byte position of the code block in the input text. + End uint32 // The end byte position of the code block in the input text. + T string // The language type of the code block. + Index int // The index of the code block in the sequence of extracted blocks. + Package string // The package name extracted from the code block. + Options []string // The execution options extracted from the code block comments. } // getCodeBlocks extracts all code blocks from the provided markdown text. @@ -79,7 +72,7 @@ func extractCodeBlocks(rootNode *sitter.Node, body string) []CodeBlock { var traverse func(node *sitter.Node) traverse = func(node *sitter.Node) { if node.Type() == CODE_FENCE_CONTENT { - codeBlock := createCodeBlock(node, body, len(codeBlocks)) + codeBlock := CreateCodeBlock(node, body, len(codeBlocks)) codeBlocks = append(codeBlocks, codeBlock) } @@ -93,8 +86,8 @@ func extractCodeBlocks(rootNode *sitter.Node, body string) []CodeBlock { return codeBlocks } -// createCodeBlock creates a CodeBlock from a code fence content node. -func createCodeBlock(node *sitter.Node, body string, index int) CodeBlock { +// CreateCodeBlock creates a CodeBlock from a code fence content node. +func CreateCodeBlock(node *sitter.Node, body string, index int) CodeBlock { startByte := node.StartByte() endByte := node.EndByte() content := body[startByte:endByte] @@ -102,12 +95,21 @@ func createCodeBlock(node *sitter.Node, body string, index int) CodeBlock { language := detectLanguage(node, body) startByte, endByte, content = adjustContentBoundaries(node, startByte, endByte, content, body) + pkgName := "" + if language == "go" { + pkgName = extractPackageName(content) + } + + options := extractOptions(content) + return CodeBlock{ Content: content, Start: startByte, End: endByte, T: language, Index: index, + Package: pkgName, + Options: options, } } @@ -178,3 +180,49 @@ func findEndMarkerNode(parentNode *sitter.Node) *sitter.Node { return nil } + +func extractPackageName(content string) string { + fset := token.NewFileSet() + + node, err := parser.ParseFile(fset, "", content, parser.PackageClauseOnly) + if err != nil { + fmt.Println("Failed to parse package name:", err) + return "" + } + + if node.Name != nil { + return node.Name.Name + } + + return "" +} + +func extractOptions(content string) []string { + fset := token.NewFileSet() + node, err := parser.ParseFile(fset, "", content, parser.ParseComments) + if err != nil { + fmt.Println("Failed to parse options:", err) + return nil + } + + opts := make([]string, 0) + for _, commentGroup := range node.Comments { + for _, comment := range commentGroup.List { + if strings.HasPrefix(comment.Text, "//gno:") { + opt := strings.TrimPrefix(comment.Text, "//gno:") + opts = append(opts, strings.TrimSpace(opt)) + } + } + } + + return opts +} + +func (c *CodeBlock) ContainsOptions(target string) bool { + for _, option := range c.Options { + if option == target { + return true + } + } + return false +} From e53482b88c38b86bcbea74390758c2850b47ba46 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Mon, 17 Jun 2024 15:58:33 +0900 Subject: [PATCH 10/37] save: need to handle import expr later --- gnovm/pkg/doctest/exec.go | 51 +++++++++++++++++++++++++++-- gnovm/pkg/doctest/exec_test.go | 57 +++++++++++++++++++++++++++++++++ gnovm/pkg/doctest/parser.go | 18 +++++++++++ gnovm/pkg/gnolang/machine.go | 2 +- gnovm/pkg/gnolang/nodes_test.go | 49 ++++++++++++++++++++++++++++ 5 files changed, 173 insertions(+), 4 deletions(-) create mode 100644 gnovm/pkg/gnolang/nodes_test.go diff --git a/gnovm/pkg/doctest/exec.go b/gnovm/pkg/doctest/exec.go index 5a2ea99c02b..9fcc16a5ed7 100644 --- a/gnovm/pkg/doctest/exec.go +++ b/gnovm/pkg/doctest/exec.go @@ -3,6 +3,7 @@ package doctest import ( "bytes" "fmt" + "strings" gno "github.com/gnolang/gno/gnovm/pkg/gnolang" "github.com/gnolang/gno/tm2/pkg/db/memdb" @@ -33,15 +34,50 @@ func ExecuteCodeBlock(c CodeBlock) (string, error) { baseStore := dbadapter.StoreConstructor(db, stypes.StoreOptions{}) iavlStore := iavl.StoreConstructor(db, stypes.StoreOptions{}) store := gno.NewStore(nil, baseStore, iavlStore) + store.SetStrictGo2GnoMapping(true) m := gno.NewMachine("main", store) - m.RunMemPackageWithOverrides(&std.MemPackage{ + + importPaths := extractImportPaths(c.Content) + for _, path := range importPaths { + if !gno.IsRealmPath(path) { + pkgName := defaultPkgName(path) + pn := gno.NewPackageNode(pkgName, path, &gno.FileSet{}) + pv := pn.NewPackage() + store.SetBlockNode(pn) + store.SetCachePackage(pv) + m.SetActivePackage(pv) + } else { + dir := "gnovm/stdlibs/" + path + memPkg := gno.ReadMemPackage(dir, path) + m.RunMemPackage(memPkg, true) + } + } + + memPkg := &std.MemPackage{ Name: c.Package, Path: c.Package, Files: []*std.MemFile{ - {Name: fmt.Sprintf("%d.%s", c.Index, c.T), Body: c.Content}, + { + Name: fmt.Sprintf("%d.%s", c.Index, c.T), + Body: c.Content, + }, }, - }, true) + } + + if !gno.IsRealmPath(c.Package) { + pkgName := defaultPkgName(c.Package) + pn := gno.NewPackageNode(pkgName, c.Package, &gno.FileSet{}) + pv := pn.NewPackage() + store.SetBlockNode(pn) + m.SetActivePackage(pv) + m.RunMemPackage(memPkg, true) + } else { + store.ClearCache() + m.PreprocessAllFilesAndSaveBlockNodes() + pv := store.GetPackage(c.Package, false) + m.SetActivePackage(pv) + } // Capture output var output bytes.Buffer @@ -60,3 +96,12 @@ func ExecuteCodeBlock(c CodeBlock) (string, error) { return result, nil } + +func defaultPkgName(gopkgPath string) gno.Name { + parts := strings.Split(gopkgPath, "/") + last := parts[len(parts)-1] + parts = strings.Split(last, "-") + name := parts[len(parts)-1] + name = strings.ToLower(name) + return gno.Name(name) +} diff --git a/gnovm/pkg/doctest/exec_test.go b/gnovm/pkg/doctest/exec_test.go index 584fb148826..752e2bb3cea 100644 --- a/gnovm/pkg/doctest/exec_test.go +++ b/gnovm/pkg/doctest/exec_test.go @@ -98,6 +98,63 @@ splay a t = rebuild $ path a t [(undefined,t)]`, } } +func TestExecuteCodeBlock_ImportPackage(t *testing.T) { + t.Skip("skipping test for now") + tests := []struct { + name string + codeBlock CodeBlock + expected string + }{ + { + name: "import go stdlib package", + codeBlock: CodeBlock{ + Content: `package main + +import ( + "strings" +) + +func main() { + println(strings.Join([]string{"Hello", "World"}, ", ")) +}`, + T: "go", + Package: "main", + }, + expected: "Hello, World\n", + }, + { + name: "import realm", + codeBlock: CodeBlock{ + Content: `package main + +import ( + "gno.land/p/demo/ufmt" +) + +func main() { + ufmt.Println("Hello, World!") +}`, + T: "go", + Package: "main", + }, + expected: "Hello, World!\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := ExecuteCodeBlock(tt.codeBlock) + if err != nil { + t.Errorf("%s returned an error: %v", tt.name, err) + } + + if res != tt.expected { + t.Errorf("%s = %v, want %v", tt.name, res, tt.expected) + } + }) + } +} + func TestExecuteCodeBlock_ShouldPanic(t *testing.T) { tests := []struct { name string diff --git a/gnovm/pkg/doctest/parser.go b/gnovm/pkg/doctest/parser.go index b775fb95697..439a6310da8 100644 --- a/gnovm/pkg/doctest/parser.go +++ b/gnovm/pkg/doctest/parser.go @@ -5,6 +5,7 @@ import ( "fmt" "go/parser" "go/token" + "strconv" "strings" sitter "github.com/smacker/go-tree-sitter" @@ -197,6 +198,22 @@ func extractPackageName(content string) string { return "" } +func extractImportPaths(code string) []string { + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "", code, parser.ImportsOnly) + if err != nil { + panic(err) + } + + importPaths := make([]string, 0) + for _, imp := range file.Imports { + path, _ := strconv.Unquote(imp.Path.Value) + importPaths = append(importPaths, path) + } + + return importPaths +} + func extractOptions(content string) []string { fset := token.NewFileSet() node, err := parser.ParseFile(fset, "", content, parser.ParseComments) @@ -208,6 +225,7 @@ func extractOptions(content string) []string { opts := make([]string, 0) for _, commentGroup := range node.Comments { for _, comment := range commentGroup.List { + // TODO: ignore whitespace if strings.HasPrefix(comment.Text, "//gno:") { opt := strings.TrimPrefix(comment.Text, "//gno:") opts = append(opts, strings.TrimSpace(opt)) diff --git a/gnovm/pkg/gnolang/machine.go b/gnovm/pkg/gnolang/machine.go index 48a2145af3a..f7ca788b850 100644 --- a/gnovm/pkg/gnolang/machine.go +++ b/gnovm/pkg/gnolang/machine.go @@ -217,7 +217,7 @@ func (m *Machine) SetActivePackage(pv *PackageValue) { // This is a temporary measure until we optimize/make-lazy. // // NOTE: package paths not beginning with gno.land will be allowed to override, -// to support cases of stdlibs processed through [RunMemPackagesWithOverrides]. +// to support cases of stdlibs processed through [RunMemPackageWithOverrides]. func (m *Machine) PreprocessAllFilesAndSaveBlockNodes() { ch := m.Store.IterMemPackage() for memPkg := range ch { diff --git a/gnovm/pkg/gnolang/nodes_test.go b/gnovm/pkg/gnolang/nodes_test.go new file mode 100644 index 00000000000..e2a7b087b66 --- /dev/null +++ b/gnovm/pkg/gnolang/nodes_test.go @@ -0,0 +1,49 @@ +package gnolang + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReadMemPackage(t *testing.T) { + tempDir, err := os.MkdirTemp("", "testpkg") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + // Create valid files + validFiles := []string{"file1.gno", "README.md", "LICENSE", "gno.mod"} + for _, f := range validFiles { + err := os.WriteFile(filepath.Join(tempDir, f), []byte(` + package main + + import ( + "gno.land/p/demo/ufmt" + ) + + func main() { + ufmt.Printfln("Hello, World!") + }`), 0o644) + require.NoError(t, err) + } + + // Create invalid files + invalidFiles := []string{".hiddenfile", "unsupported.txt"} + for _, f := range invalidFiles { + err := os.WriteFile(filepath.Join(tempDir, f), []byte("content"), 0o644) + require.NoError(t, err) + } + + // Test Case 1: Valid Package Directory + memPkg := ReadMemPackage(tempDir, "testpkg") + require.NotNil(t, memPkg) + assert.Len(t, memPkg.Files, len(validFiles), "MemPackage should contain only valid files") + + // Test Case 2: Non-existent Directory + assert.Panics(t, func() { + ReadMemPackage("/non/existent/dir", "testpkg") + }, "Expected panic for non-existent directory") +} From f0df3b35f08727984d9f69f2b8a0bf3348969816 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Mon, 17 Jun 2024 16:32:14 +0900 Subject: [PATCH 11/37] basic CLI --- gnovm/cmd/gno/doctest.go | 77 ++++++++++++++++++++++++++++++++ gnovm/cmd/gno/doctest_test.go | 19 ++++++++ gnovm/cmd/gno/main.go | 1 + gnovm/pkg/doctest/parser.go | 4 +- gnovm/pkg/doctest/parser_test.go | 2 +- 5 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 gnovm/cmd/gno/doctest.go create mode 100644 gnovm/cmd/gno/doctest_test.go diff --git a/gnovm/cmd/gno/doctest.go b/gnovm/cmd/gno/doctest.go new file mode 100644 index 00000000000..7f5ac956578 --- /dev/null +++ b/gnovm/cmd/gno/doctest.go @@ -0,0 +1,77 @@ +package main + +import ( + "context" + "flag" + "fmt" + + "github.com/gnolang/gno/gnovm/pkg/doctest" + "github.com/gnolang/gno/tm2/pkg/commands" +) + +type doctestCfg struct { + path string + index int +} + +func newDoctestCmd() *commands.Command { + cfg := &doctestCfg{} + + return commands.NewCommand( + commands.Metadata{ + Name: "doctest", + ShortUsage: "doctest [flags]", + ShortHelp: "executes a code block from a markdown file", + }, + cfg, + func(_ context.Context, args []string) error { + return execDoctest(cfg, args) + }, + ) +} + +func (c *doctestCfg) RegisterFlags(fs *flag.FlagSet) { + fs.StringVar( + &c.path, + "markdown-path", + "", + "path to the markdown file", + ) + + fs.IntVar( + &c.index, + "index", + 0, + "index of the code block to execute", + ) +} + +func execDoctest(cfg *doctestCfg, args []string) error { + if len(args) > 0 { + return flag.ErrHelp + } + + if cfg.path == "" { + return fmt.Errorf("missing markdown-path flag. Please provide a path to the markdown file") + } + + content, err := doctest.ReadMarkdownFile(cfg.path) + if err != nil { + return err + } + + codeBlocks := doctest.GetCodeBlocks(content) + if cfg.index >= len(codeBlocks) { + return fmt.Errorf("code block index out of range. max index: %d", len(codeBlocks)-1) + } + + codeblock := codeBlocks[cfg.index] + result, err := doctest.ExecuteCodeBlock(codeblock) + if err != nil { + return err + } + + fmt.Println(result) + + return nil +} diff --git a/gnovm/cmd/gno/doctest_test.go b/gnovm/cmd/gno/doctest_test.go new file mode 100644 index 00000000000..e2e8b2a12ac --- /dev/null +++ b/gnovm/cmd/gno/doctest_test.go @@ -0,0 +1,19 @@ +package main + +import ( + "strings" + "testing" +) + +func Test_execDoctest_InvalidPath(t *testing.T) { + cfg := &doctestCfg{ + path: "", + index: 0, + } + args := []string{} + + err := execDoctest(cfg, args) + if err == nil || !strings.Contains(err.Error(), "missing markdown-path flag") { + t.Errorf("execDoctest should fail with missing path error, got: %v", err) + } +} diff --git a/gnovm/cmd/gno/main.go b/gnovm/cmd/gno/main.go index 8b77cfd2a10..c9aa8a831f1 100644 --- a/gnovm/cmd/gno/main.go +++ b/gnovm/cmd/gno/main.go @@ -34,6 +34,7 @@ func newGnocliCmd(io commands.IO) *commands.Command { newDocCmd(io), newEnvCmd(io), newBugCmd(io), + newDoctestCmd(), // fmt -- gofmt // graph // vendor -- download deps from the chain in vendor/ diff --git a/gnovm/pkg/doctest/parser.go b/gnovm/pkg/doctest/parser.go index 439a6310da8..d600764e408 100644 --- a/gnovm/pkg/doctest/parser.go +++ b/gnovm/pkg/doctest/parser.go @@ -39,8 +39,8 @@ type CodeBlock struct { Options []string // The execution options extracted from the code block comments. } -// getCodeBlocks extracts all code blocks from the provided markdown text. -func getCodeBlocks(body string) []CodeBlock { +// GetCodeBlocks extracts all code blocks from the provided markdown text. +func GetCodeBlocks(body string) []CodeBlock { parser := createParser() tree, err := parseMarkdown(parser, body) if err != nil { diff --git a/gnovm/pkg/doctest/parser_test.go b/gnovm/pkg/doctest/parser_test.go index 35a12154400..519bc785f29 100644 --- a/gnovm/pkg/doctest/parser_test.go +++ b/gnovm/pkg/doctest/parser_test.go @@ -94,7 +94,7 @@ func TestGetCodeBlocks(t *testing.T) { for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { - result := getCodeBlocks(tt.input) + result := GetCodeBlocks(tt.input) if len(result) != len(tt.expected) { t.Errorf("Failed %s: expected %d code blocks, got %d", tt.name, len(tt.expected), len(result)) } From 599bd6ff748dbb50f1e42d0e016a00b5c6236db4 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Tue, 18 Jun 2024 15:12:03 +0900 Subject: [PATCH 12/37] execute imported function --- gnovm/pkg/doctest/exec.go | 125 +++++++++++++---------------- gnovm/pkg/doctest/exec_test.go | 141 +++++---------------------------- 2 files changed, 71 insertions(+), 195 deletions(-) diff --git a/gnovm/pkg/doctest/exec.go b/gnovm/pkg/doctest/exec.go index 9fcc16a5ed7..df91ae1134d 100644 --- a/gnovm/pkg/doctest/exec.go +++ b/gnovm/pkg/doctest/exec.go @@ -1,16 +1,21 @@ package doctest import ( - "bytes" "fmt" - "strings" - gno "github.com/gnolang/gno/gnovm/pkg/gnolang" + vmm "github.com/gnolang/gno/gno.land/pkg/sdk/vm" + bft "github.com/gnolang/gno/tm2/pkg/bft/types" + "github.com/gnolang/gno/tm2/pkg/crypto" "github.com/gnolang/gno/tm2/pkg/db/memdb" + "github.com/gnolang/gno/tm2/pkg/log" + "github.com/gnolang/gno/tm2/pkg/sdk" + "github.com/gnolang/gno/tm2/pkg/sdk/auth" + bankm "github.com/gnolang/gno/tm2/pkg/sdk/bank" "github.com/gnolang/gno/tm2/pkg/std" + "github.com/gnolang/gno/tm2/pkg/store" "github.com/gnolang/gno/tm2/pkg/store/dbadapter" "github.com/gnolang/gno/tm2/pkg/store/iavl" - stypes "github.com/gnolang/gno/tm2/pkg/store/types" + "github.com/gnolang/gno/tm2/pkg/store/types" ) const ( @@ -19,89 +24,65 @@ const ( NO_RUN = "no_run" ) +const LIBS_DIR = "../../stdlibs" + func ExecuteCodeBlock(c CodeBlock) (string, error) { if c.ContainsOptions(IGNORE) { return "", nil } - if c.T == "go" { - c.T = "gno" - } else if c.T != "gno" { - return "", fmt.Errorf("unsupported language: %s", c.T) + err := validateCodeBlock(c) + if err != nil { + return "", err } - db := memdb.NewMemDB() - baseStore := dbadapter.StoreConstructor(db, stypes.StoreOptions{}) - iavlStore := iavl.StoreConstructor(db, stypes.StoreOptions{}) - store := gno.NewStore(nil, baseStore, iavlStore) - store.SetStrictGo2GnoMapping(true) - - m := gno.NewMachine("main", store) - - importPaths := extractImportPaths(c.Content) - for _, path := range importPaths { - if !gno.IsRealmPath(path) { - pkgName := defaultPkgName(path) - pn := gno.NewPackageNode(pkgName, path, &gno.FileSet{}) - pv := pn.NewPackage() - store.SetBlockNode(pn) - store.SetCachePackage(pv) - m.SetActivePackage(pv) - } else { - dir := "gnovm/stdlibs/" + path - memPkg := gno.ReadMemPackage(dir, path) - m.RunMemPackage(memPkg, true) - } - } + baseCapKey := store.NewStoreKey("baseCapKey") + iavlCapKey := store.NewStoreKey("iavlCapKey") - memPkg := &std.MemPackage{ - Name: c.Package, - Path: c.Package, - Files: []*std.MemFile{ - { - Name: fmt.Sprintf("%d.%s", c.Index, c.T), - Body: c.Content, - }, - }, - } + ms, ctx := setupMultiStore(baseCapKey, iavlCapKey) - if !gno.IsRealmPath(c.Package) { - pkgName := defaultPkgName(c.Package) - pn := gno.NewPackageNode(pkgName, c.Package, &gno.FileSet{}) - pv := pn.NewPackage() - store.SetBlockNode(pn) - m.SetActivePackage(pv) - m.RunMemPackage(memPkg, true) - } else { - store.ClearCache() - m.PreprocessAllFilesAndSaveBlockNodes() - pv := store.GetPackage(c.Package, false) - m.SetActivePackage(pv) - } + acck := auth.NewAccountKeeper(iavlCapKey, std.ProtoBaseAccount) + bank := bankm.NewBankKeeper(acck) - // Capture output - var output bytes.Buffer - m.Output = &output + vmk := vmm.NewVMKeeper(baseCapKey, iavlCapKey, acck, bank, LIBS_DIR, 100_000_000) - if c.ContainsOptions(NO_RUN) { - return "", nil - } + vmk.Initialize(ms.MultiCacheWrap()) - m.RunMain() + addr := crypto.AddressFromPreimage([]byte("addr1")) + acc := acck.NewAccountWithAddress(ctx, addr) + acck.SetAccount(ctx, acc) - result := output.String() - if c.ContainsOptions(SHOULD_PANIC) { - return "", fmt.Errorf("expected panic, got %q", result) + files := []*std.MemFile{ + {Name: fmt.Sprintf("%d.%s", c.Index, c.T), Body: c.Content}, } - return result, nil + coins := std.MustParseCoins("") + msg2 := vmm.NewMsgRun(addr, coins, files) + res, err := vmk.Run(ctx, msg2) + if err != nil { + return "", err + } + + return res, nil +} + +func validateCodeBlock(c CodeBlock) error { + if c.T == "go" { + c.T = "gno" + } else if c.T != "gno" { + return fmt.Errorf("unsupported language: %s", c.T) + } + return nil } -func defaultPkgName(gopkgPath string) gno.Name { - parts := strings.Split(gopkgPath, "/") - last := parts[len(parts)-1] - parts = strings.Split(last, "-") - name := parts[len(parts)-1] - name = strings.ToLower(name) - return gno.Name(name) +func setupMultiStore(baseKey, iavlKey types.StoreKey) (types.CommitMultiStore, sdk.Context) { + db := memdb.NewMemDB() + + ms := store.NewCommitMultiStore(db) + ms.MountStoreWithDB(baseKey, dbadapter.StoreConstructor, db) + ms.MountStoreWithDB(iavlKey, iavl.StoreConstructor, db) + ms.LoadLatestVersion() + + ctx := sdk.NewContext(sdk.RunTxModeDeliver, ms, &bft.Header{ChainID: "chain-id"}, log.NewNoopLogger()) + return ms, ctx } diff --git a/gnovm/pkg/doctest/exec_test.go b/gnovm/pkg/doctest/exec_test.go index 752e2bb3cea..3f50795f13a 100644 --- a/gnovm/pkg/doctest/exec_test.go +++ b/gnovm/pkg/doctest/exec_test.go @@ -6,142 +6,63 @@ import ( ) func TestExecuteCodeBlock(t *testing.T) { + t.Parallel() tests := []struct { name string codeBlock CodeBlock expected string - isErr bool }{ { - name: "Hello, World!", + name: "import go stdlib package", codeBlock: CodeBlock{ Content: ` package main func main() { - println("Hello, World!") + println("Hello, World") }`, - T: "go", + T: "gno", Package: "main", }, - expected: "Hello, World!\n", + expected: "Hello, World\n", }, { - name: "Multiple prints", + name: "import go stdlib package", codeBlock: CodeBlock{ Content: ` package main -func main() { - println("Hello"); - println("World") -}`, - T: "go", - Package: "main", - }, - expected: "Hello\nWorld\n", - }, - { - name: "Print variables", - codeBlock: CodeBlock{ - Content: ` -package main +import "std" func main() { - a := 10 - b := 20 - println(a + b) + addr := std.GetOrigCaller() + println(addr) }`, - T: "go", + T: "gno", Package: "main", }, - expected: "30\n", + expected: "g14ch5q26mhx3jk5cxl88t278nper264ces4m8nt\n", }, - { - name: "unsupported language", - codeBlock: CodeBlock{ - Content: ` -data Tree a = Empty | Node a (Tree a) (Tree a) - deriving (Eq, Show) - -data Direction = LH | RH - deriving (Eq, Show) - -splay :: (Ord a) => a -> Tree a -> Tree a -splay a t = rebuild $ path a t [(undefined,t)]`, - T: "haskell", - }, - isErr: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // res, err := executeCodeBlock(tc.codeBlock) - res, err := ExecuteCodeBlock(tc.codeBlock) - if tc.isErr && err == nil { - t.Errorf("%s did not return an error", tc.name) - } - - if res != tc.expected { - t.Errorf("%s = %v, want %v", tc.name, res, tc.expected) - } - - if tc.codeBlock.T == "go" { - if tc.codeBlock.Package != "" { - if tc.codeBlock.Package != "main" { - t.Errorf("%s = %v, want %v", tc.name, tc.codeBlock.Package, "main") - } - } - } - }) - } -} - -func TestExecuteCodeBlock_ImportPackage(t *testing.T) { - t.Skip("skipping test for now") - tests := []struct { - name string - codeBlock CodeBlock - expected string - }{ { name: "import go stdlib package", codeBlock: CodeBlock{ - Content: `package main + Content: ` +package main -import ( - "strings" -) +import "strings" func main() { - println(strings.Join([]string{"Hello", "World"}, ", ")) + println(strings.ToUpper("Hello, World")) }`, - T: "go", + T: "gno", Package: "main", }, - expected: "Hello, World\n", - }, - { - name: "import realm", - codeBlock: CodeBlock{ - Content: `package main - -import ( - "gno.land/p/demo/ufmt" -) - -func main() { - ufmt.Println("Hello, World!") -}`, - T: "go", - Package: "main", - }, - expected: "Hello, World!\n", + expected: "HELLO, WORLD\n", }, } for _, tt := range tests { + tt := tt t.Run(tt.name, func(t *testing.T) { res, err := ExecuteCodeBlock(tt.codeBlock) if err != nil { @@ -155,32 +76,6 @@ func main() { } } -func TestExecuteCodeBlock_ShouldPanic(t *testing.T) { - tests := []struct { - name string - codeBlock CodeBlock - }{ - { - name: "syntax error", - codeBlock: CodeBlock{ - Content: "package main\n\nfunc main() { println(\"Hello, World!\")", - T: "go", - }, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Errorf("%s did not panic", tc.name) - } - }() - _, _ = ExecuteCodeBlock(tc.codeBlock) - }) - } -} - func TestExtractOptions(t *testing.T) { tests := []struct { name string From da567e3d2ef38f8217351e7ee0ab1d696011a970 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Tue, 18 Jun 2024 17:28:30 +0900 Subject: [PATCH 13/37] save --- gnovm/pkg/doctest/exec.go | 21 ++++--- gnovm/pkg/doctest/exec_test.go | 99 +++++++++++--------------------- gnovm/pkg/doctest/parser.go | 51 ---------------- gnovm/pkg/doctest/parser_test.go | 89 +++++++++++++++++++++++++++- 4 files changed, 131 insertions(+), 129 deletions(-) diff --git a/gnovm/pkg/doctest/exec.go b/gnovm/pkg/doctest/exec.go index df91ae1134d..b0a12ad2248 100644 --- a/gnovm/pkg/doctest/exec.go +++ b/gnovm/pkg/doctest/exec.go @@ -3,7 +3,7 @@ package doctest import ( "fmt" - vmm "github.com/gnolang/gno/gno.land/pkg/sdk/vm" + "github.com/gnolang/gno/gno.land/pkg/sdk/vm" bft "github.com/gnolang/gno/tm2/pkg/bft/types" "github.com/gnolang/gno/tm2/pkg/crypto" "github.com/gnolang/gno/tm2/pkg/db/memdb" @@ -18,19 +18,17 @@ import ( "github.com/gnolang/gno/tm2/pkg/store/types" ) +// Option constants const ( - IGNORE = "ignore" - SHOULD_PANIC = "should_panic" - NO_RUN = "no_run" + IGNORE = "ignore" // Do not run the code block + SHOULD_PANIC = "should_panic" // Expect a panic + ASSERT = "assert" // Assert the result and expected output are equal ) -const LIBS_DIR = "../../stdlibs" +const STDLIBS_DIR = "../../stdlibs" +// ExecuteCodeBlock executes a parsed code block and executes it in a gno VM. func ExecuteCodeBlock(c CodeBlock) (string, error) { - if c.ContainsOptions(IGNORE) { - return "", nil - } - err := validateCodeBlock(c) if err != nil { return "", err @@ -44,7 +42,7 @@ func ExecuteCodeBlock(c CodeBlock) (string, error) { acck := auth.NewAccountKeeper(iavlCapKey, std.ProtoBaseAccount) bank := bankm.NewBankKeeper(acck) - vmk := vmm.NewVMKeeper(baseCapKey, iavlCapKey, acck, bank, LIBS_DIR, 100_000_000) + vmk := vm.NewVMKeeper(baseCapKey, iavlCapKey, acck, bank, STDLIBS_DIR, 100_000_000) vmk.Initialize(ms.MultiCacheWrap()) @@ -57,7 +55,8 @@ func ExecuteCodeBlock(c CodeBlock) (string, error) { } coins := std.MustParseCoins("") - msg2 := vmm.NewMsgRun(addr, coins, files) + msg2 := vm.NewMsgRun(addr, coins, files) + res, err := vmk.Run(ctx, msg2) if err != nil { return "", err diff --git a/gnovm/pkg/doctest/exec_test.go b/gnovm/pkg/doctest/exec_test.go index 3f50795f13a..b88f64aa0b9 100644 --- a/gnovm/pkg/doctest/exec_test.go +++ b/gnovm/pkg/doctest/exec_test.go @@ -1,7 +1,6 @@ package doctest import ( - "reflect" "testing" ) @@ -59,87 +58,55 @@ func main() { }, expected: "HELLO, WORLD\n", }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - res, err := ExecuteCodeBlock(tt.codeBlock) - if err != nil { - t.Errorf("%s returned an error: %v", tt.name, err) - } - - if res != tt.expected { - t.Errorf("%s = %v, want %v", tt.name, res, tt.expected) - } - }) - } -} - -func TestExtractOptions(t *testing.T) { - tests := []struct { - name string - input string - expected []string - }{ - { - name: "Ignore option", - input: ` -//gno: ignore -package main - -func main() { - println("This code should be ignored") -} -`, - expected: []string{"ignore"}, - }, { - name: "No run option", - input: ` -//gno: no_run + name: "print multiple values", + codeBlock: CodeBlock{ + Content: ` package main func main() { - println("This code should not run") -} -`, - expected: []string{"no_run"}, + count := 3 + for i := 0; i < count; i++ { + println("Hello") + } +}`, + T: "gno", + Package: "main", + }, + expected: "Hello\nHello\nHello\n", }, { - name: "Should panic option", - input: ` -//gno: should_panic + name: "import multiple go stdlib packages", + codeBlock: CodeBlock{ + Content: ` package main -func main() { - panic("Expected panic") -} -`, - expected: []string{"should_panic"}, - }, - { - name: "No options", - input: ` -package main +import ( + "math" + "strings" +) func main() { - println("No options") -} -`, - expected: []string{}, + println(math.Pi) + println(strings.ToUpper("Hello, World")) +}`, + T: "gno", + Package: "main", + }, + expected: "3.141592653589793\nHELLO, WORLD\n", }, } for _, tt := range tests { + tt := tt t.Run(tt.name, func(t *testing.T) { - codeBlock := CodeBlock{ - Content: tt.input, - T: "go", + res, err := ExecuteCodeBlock(tt.codeBlock) + if err != nil { + t.Errorf("%s returned an error: %v", tt.name, err) } - options := extractOptions(codeBlock.Content) - if !reflect.DeepEqual(options, tt.expected) { - t.Errorf("got %v, want %v", options, tt.expected) + + if res != tt.expected { + t.Errorf("%s = %v, want %v", tt.name, res, tt.expected) } }) } diff --git a/gnovm/pkg/doctest/parser.go b/gnovm/pkg/doctest/parser.go index d600764e408..16c3e64c36c 100644 --- a/gnovm/pkg/doctest/parser.go +++ b/gnovm/pkg/doctest/parser.go @@ -5,7 +5,6 @@ import ( "fmt" "go/parser" "go/token" - "strconv" "strings" sitter "github.com/smacker/go-tree-sitter" @@ -101,8 +100,6 @@ func CreateCodeBlock(node *sitter.Node, body string, index int) CodeBlock { pkgName = extractPackageName(content) } - options := extractOptions(content) - return CodeBlock{ Content: content, Start: startByte, @@ -110,7 +107,6 @@ func CreateCodeBlock(node *sitter.Node, body string, index int) CodeBlock { T: language, Index: index, Package: pkgName, - Options: options, } } @@ -197,50 +193,3 @@ func extractPackageName(content string) string { return "" } - -func extractImportPaths(code string) []string { - fset := token.NewFileSet() - file, err := parser.ParseFile(fset, "", code, parser.ImportsOnly) - if err != nil { - panic(err) - } - - importPaths := make([]string, 0) - for _, imp := range file.Imports { - path, _ := strconv.Unquote(imp.Path.Value) - importPaths = append(importPaths, path) - } - - return importPaths -} - -func extractOptions(content string) []string { - fset := token.NewFileSet() - node, err := parser.ParseFile(fset, "", content, parser.ParseComments) - if err != nil { - fmt.Println("Failed to parse options:", err) - return nil - } - - opts := make([]string, 0) - for _, commentGroup := range node.Comments { - for _, comment := range commentGroup.List { - // TODO: ignore whitespace - if strings.HasPrefix(comment.Text, "//gno:") { - opt := strings.TrimPrefix(comment.Text, "//gno:") - opts = append(opts, strings.TrimSpace(opt)) - } - } - } - - return opts -} - -func (c *CodeBlock) ContainsOptions(target string) bool { - for _, option := range c.Options { - if option == target { - return true - } - } - return false -} diff --git a/gnovm/pkg/doctest/parser_test.go b/gnovm/pkg/doctest/parser_test.go index 519bc785f29..9c432a15934 100644 --- a/gnovm/pkg/doctest/parser_test.go +++ b/gnovm/pkg/doctest/parser_test.go @@ -32,7 +32,7 @@ func TestGetCodeBlocks(t *testing.T) { { Content: "fmt.Println(\"Hello, World!\")", Start: 6, - End: 41, // TODO: should be 38 + End: 41, T: "go", Index: 0, }, @@ -124,6 +124,93 @@ func TestGetCodeBlocks(t *testing.T) { } } +// func TestExtractOptions(t *testing.T) { +// tests := []struct { +// name string +// input string +// expected []string +// }{ +// { +// name: "Single option", +// input: ` +// //gno: no_run +// package main + +// func main() { +// println("This code should not run") +// } +// `, +// expected: []string{"no_run"}, +// }, +// { +// name: "Multiple options", +// input: ` +// //gno: no_run, should_panic +// package main + +// func main() { +// panic("Expected panic") +// } +// `, +// expected: []string{"no_run", "should_panic"}, +// }, +// { +// name: "Option with sub-command", +// input: ` +// //gno: assert 1 2 3 +// package main + +// func main() { +// println(1) +// println(2) +// println(3) +// } +// `, +// expected: []string{"assert 1 2 3\n"}, +// }, +// { +// name: "Multiple options with sub-commands", +// input: ` +// //gno: no_run, assert 1 2 3, custom_cmd sub_cmd +// package main + +// func main() { +// println(1) +// println(2) +// println(3) +// } +// `, +// expected: []string{"no_run", "assert 1 2 3", "custom_cmd sub_cmd"}, +// }, +// { +// name: "No options", +// input: ` +// package main + +// func main() { +// println("No options") +// } +// `, +// expected: []string{}, +// }, +// } + +// for _, tt := range tests { +// t.Run(tt.name, func(t *testing.T) { +// result := extractOptions(tt.input) +// if len(result) != len(tt.expected) { +// t.Errorf("Failed %s: expected %d options, got %d", tt.name, len(tt.expected), len(result)) +// } + +// for i, res := range result { +// if strings.EqualFold(res, tt.expected[i]) { +// t.Errorf("Failed %s: expected option %s, got %s", tt.name, tt.expected[i], res) +// } +// } +// }) +// } +// } + // ignore whitespace in the source code func normalize(s string) string { return strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(s, "\n", ""), "\r", ""), "\t", ""), " ", "") From 2d363bc84f1be374fa784f620b20952b8d6dda55 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Tue, 18 Jun 2024 20:16:32 +0900 Subject: [PATCH 14/37] fix: doctest CLI --- gnovm/cmd/gno/doctest.go | 53 ++++++++++++++++---------------- gnovm/cmd/gno/doctest_test.go | 43 ++++++++++++++++++++------ gnovm/cmd/gno/main.go | 2 +- gnovm/pkg/doctest/exec.go | 21 ++++--------- gnovm/pkg/doctest/exec_test.go | 11 ++++++- gnovm/pkg/doctest/io_test.go | 39 +++++++++++++++++++++++ gnovm/pkg/doctest/parser_test.go | 6 ++-- 7 files changed, 119 insertions(+), 56 deletions(-) create mode 100644 gnovm/pkg/doctest/io_test.go diff --git a/gnovm/cmd/gno/doctest.go b/gnovm/cmd/gno/doctest.go index 7f5ac956578..64bd76b1d69 100644 --- a/gnovm/cmd/gno/doctest.go +++ b/gnovm/cmd/gno/doctest.go @@ -5,73 +5,74 @@ import ( "flag" "fmt" - "github.com/gnolang/gno/gnovm/pkg/doctest" + dt "github.com/gnolang/gno/gnovm/pkg/doctest" "github.com/gnolang/gno/tm2/pkg/commands" ) type doctestCfg struct { - path string - index int + markdownPath string + codeIndex int } -func newDoctestCmd() *commands.Command { +func newDoctestCmd(io commands.IO) *commands.Command { cfg := &doctestCfg{} return commands.NewCommand( commands.Metadata{ Name: "doctest", - ShortUsage: "doctest [flags]", - ShortHelp: "executes a code block from a markdown file", + ShortUsage: "doctest -path -index ", + ShortHelp: "executes a specific code block from a markdown file", }, cfg, func(_ context.Context, args []string) error { - return execDoctest(cfg, args) + return execDoctest(cfg, args, io) }, ) } func (c *doctestCfg) RegisterFlags(fs *flag.FlagSet) { fs.StringVar( - &c.path, - "markdown-path", + &c.markdownPath, + "path", "", "path to the markdown file", ) fs.IntVar( - &c.index, + &c.codeIndex, "index", - 0, + -1, "index of the code block to execute", ) } -func execDoctest(cfg *doctestCfg, args []string) error { - if len(args) > 0 { - return flag.ErrHelp +func execDoctest(cfg *doctestCfg, args []string, io commands.IO) error { + if cfg.markdownPath == "" { + return fmt.Errorf("markdown file path is required") } - if cfg.path == "" { - return fmt.Errorf("missing markdown-path flag. Please provide a path to the markdown file") + if cfg.codeIndex < 0 { + return fmt.Errorf("code block index must be non-negative") } - content, err := doctest.ReadMarkdownFile(cfg.path) + content, err := dt.ReadMarkdownFile(cfg.markdownPath) if err != nil { - return err + return fmt.Errorf("failed to read markdown file: %w", err) } - codeBlocks := doctest.GetCodeBlocks(content) - if cfg.index >= len(codeBlocks) { - return fmt.Errorf("code block index out of range. max index: %d", len(codeBlocks)-1) + codeBlocks := dt.GetCodeBlocks(content) + if cfg.codeIndex >= len(codeBlocks) { + return fmt.Errorf("invalid code block index: %d", cfg.codeIndex) } - codeblock := codeBlocks[cfg.index] - result, err := doctest.ExecuteCodeBlock(codeblock) + selectedCodeBlock := codeBlocks[cfg.codeIndex] + result, err := dt.ExecuteCodeBlock(selectedCodeBlock, dt.STDLIBS_DIR) if err != nil { - return err + return fmt.Errorf("failed to execute code block: %w", err) } - fmt.Println(result) + io.Println("Execution Result:") + io.Println(result) return nil -} +} \ No newline at end of file diff --git a/gnovm/cmd/gno/doctest_test.go b/gnovm/cmd/gno/doctest_test.go index e2e8b2a12ac..c42f187e518 100644 --- a/gnovm/cmd/gno/doctest_test.go +++ b/gnovm/cmd/gno/doctest_test.go @@ -1,19 +1,42 @@ package main import ( - "strings" + "os" "testing" ) -func Test_execDoctest_InvalidPath(t *testing.T) { - cfg := &doctestCfg{ - path: "", - index: 0, +func TestDoctest(t *testing.T) { + tempDir, err := os.MkdirTemp("", "doctest-test") + if err != nil { + t.Fatalf("failed to create temp directory: %v", err) } - args := []string{} + defer os.RemoveAll(tempDir) - err := execDoctest(cfg, args) - if err == nil || !strings.Contains(err.Error(), "missing markdown-path flag") { - t.Errorf("execDoctest should fail with missing path error, got: %v", err) + markdownContent := "## Example\nprint hello world in gno.\n```go\npackage main\n\nfunc main() {\nprintln(\"Hello, World!\")\n}\n```" + + mdFile, err := os.CreateTemp(tempDir, "sample-*.md") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + defer mdFile.Close() + + _, err = mdFile.WriteString(markdownContent) + if err != nil { + t.Fatalf("failed to write to temp file: %v", err) } -} + + mdFilePath := mdFile.Name() + + tc := []testMainCase{ + { + args: []string{"doctest -h"}, + errShouldBe: "flag: help requested", + }, + { + args: []string{"doctest", "-path", mdFilePath, "-index", "0"}, + stdoutShouldContain: "Hello, World!\n", + }, + } + + testMainCaseRun(t, tc) +} \ No newline at end of file diff --git a/gnovm/cmd/gno/main.go b/gnovm/cmd/gno/main.go index c9aa8a831f1..d631f321947 100644 --- a/gnovm/cmd/gno/main.go +++ b/gnovm/cmd/gno/main.go @@ -34,7 +34,7 @@ func newGnocliCmd(io commands.IO) *commands.Command { newDocCmd(io), newEnvCmd(io), newBugCmd(io), - newDoctestCmd(), + newDoctestCmd(io), // fmt -- gofmt // graph // vendor -- download deps from the chain in vendor/ diff --git a/gnovm/pkg/doctest/exec.go b/gnovm/pkg/doctest/exec.go index b0a12ad2248..83976d7086b 100644 --- a/gnovm/pkg/doctest/exec.go +++ b/gnovm/pkg/doctest/exec.go @@ -28,10 +28,11 @@ const ( const STDLIBS_DIR = "../../stdlibs" // ExecuteCodeBlock executes a parsed code block and executes it in a gno VM. -func ExecuteCodeBlock(c CodeBlock) (string, error) { - err := validateCodeBlock(c) - if err != nil { - return "", err +func ExecuteCodeBlock(c CodeBlock, stdlibDir string) (string, error) { + if c.T == "go" { + c.T = "gno" + } else if c.T != "gno" { + return "", fmt.Errorf("unsupported language type: %s", c.T) } baseCapKey := store.NewStoreKey("baseCapKey") @@ -42,8 +43,7 @@ func ExecuteCodeBlock(c CodeBlock) (string, error) { acck := auth.NewAccountKeeper(iavlCapKey, std.ProtoBaseAccount) bank := bankm.NewBankKeeper(acck) - vmk := vm.NewVMKeeper(baseCapKey, iavlCapKey, acck, bank, STDLIBS_DIR, 100_000_000) - + vmk := vm.NewVMKeeper(baseCapKey, iavlCapKey, acck, bank, stdlibDir, 100_000_000) vmk.Initialize(ms.MultiCacheWrap()) addr := crypto.AddressFromPreimage([]byte("addr1")) @@ -65,15 +65,6 @@ func ExecuteCodeBlock(c CodeBlock) (string, error) { return res, nil } -func validateCodeBlock(c CodeBlock) error { - if c.T == "go" { - c.T = "gno" - } else if c.T != "gno" { - return fmt.Errorf("unsupported language: %s", c.T) - } - return nil -} - func setupMultiStore(baseKey, iavlKey types.StoreKey) (types.CommitMultiStore, sdk.Context) { db := memdb.NewMemDB() diff --git a/gnovm/pkg/doctest/exec_test.go b/gnovm/pkg/doctest/exec_test.go index b88f64aa0b9..1760da8af85 100644 --- a/gnovm/pkg/doctest/exec_test.go +++ b/gnovm/pkg/doctest/exec_test.go @@ -95,12 +95,21 @@ func main() { }, expected: "3.141592653589793\nHELLO, WORLD\n", }, + { + name: "test", + codeBlock: CodeBlock{ + Content: "package main\n\nfunc main() {\nprintln(\"Hello, World!\")\n}", + T: "gno", + Package: "main", + }, + expected: "Hello, World!\n", + }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { - res, err := ExecuteCodeBlock(tt.codeBlock) + res, err := ExecuteCodeBlock(tt.codeBlock, STDLIBS_DIR) if err != nil { t.Errorf("%s returned an error: %v", tt.name, err) } diff --git a/gnovm/pkg/doctest/io_test.go b/gnovm/pkg/doctest/io_test.go new file mode 100644 index 00000000000..2e58de99d28 --- /dev/null +++ b/gnovm/pkg/doctest/io_test.go @@ -0,0 +1,39 @@ +package doctest + +import ( + "os" + "testing" +) + +func TestReadMarkdownFile(t *testing.T) { + t.Parallel() + + tmpFile, err := os.CreateTemp("", "*.md") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + + expectedContent := "# Test Markdown\nThis is a test." + if _, err := tmpFile.WriteString(expectedContent); err != nil { + t.Fatalf("Failed to write to temp file: %v", err) + } + if err := tmpFile.Close(); err != nil { + t.Fatalf("Failed to close temp file: %v", err) + } + + // Test: Read the content of the temporary markdown file + content, err := ReadMarkdownFile(tmpFile.Name()) + if err != nil { + t.Errorf("ReadMarkdownFile returned an error: %v", err) + } + if content != expectedContent { + t.Errorf("ReadMarkdownFile content mismatch. Got %v, want %v", content, expectedContent) + } + + // Test: Attempt to read a non-existent file + _, err = ReadMarkdownFile("non_existent_file.md") + if err == nil { + t.Error("ReadMarkdownFile did not return an error for a non-existent file") + } +} diff --git a/gnovm/pkg/doctest/parser_test.go b/gnovm/pkg/doctest/parser_test.go index 9c432a15934..f8fb71e9303 100644 --- a/gnovm/pkg/doctest/parser_test.go +++ b/gnovm/pkg/doctest/parser_test.go @@ -40,12 +40,12 @@ func TestGetCodeBlocks(t *testing.T) { }, { name: "Single code block with tildes", - input: "~~~go\nfmt.Println(\"Hello, World!\")\n~~~", + input: "## Example\nprint hello world in go.\n~~~go\nfmt.Println(\"Hello, World!\")\n~~~", expected: []CodeBlock{ { Content: "fmt.Println(\"Hello, World!\")", - Start: 6, - End: 38, + Start: 42, + End: 74, T: "go", Index: 0, }, From f31a102ddf6df2cb226d7d2e04cc6cdbb3ee07ee Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Tue, 18 Jun 2024 20:17:41 +0900 Subject: [PATCH 15/37] lint --- gnovm/cmd/gno/doctest.go | 2 +- gnovm/cmd/gno/doctest_test.go | 4 +-- gnovm/pkg/doctest/io_test.go | 52 +++++++++++++++++------------------ 3 files changed, 29 insertions(+), 29 deletions(-) diff --git a/gnovm/cmd/gno/doctest.go b/gnovm/cmd/gno/doctest.go index 64bd76b1d69..9fb14ec4a7e 100644 --- a/gnovm/cmd/gno/doctest.go +++ b/gnovm/cmd/gno/doctest.go @@ -75,4 +75,4 @@ func execDoctest(cfg *doctestCfg, args []string, io commands.IO) error { io.Println(result) return nil -} \ No newline at end of file +} diff --git a/gnovm/cmd/gno/doctest_test.go b/gnovm/cmd/gno/doctest_test.go index c42f187e518..dd6ab2e0e76 100644 --- a/gnovm/cmd/gno/doctest_test.go +++ b/gnovm/cmd/gno/doctest_test.go @@ -33,10 +33,10 @@ func TestDoctest(t *testing.T) { errShouldBe: "flag: help requested", }, { - args: []string{"doctest", "-path", mdFilePath, "-index", "0"}, + args: []string{"doctest", "-path", mdFilePath, "-index", "0"}, stdoutShouldContain: "Hello, World!\n", }, } testMainCaseRun(t, tc) -} \ No newline at end of file +} diff --git a/gnovm/pkg/doctest/io_test.go b/gnovm/pkg/doctest/io_test.go index 2e58de99d28..8f86c113c24 100644 --- a/gnovm/pkg/doctest/io_test.go +++ b/gnovm/pkg/doctest/io_test.go @@ -6,34 +6,34 @@ import ( ) func TestReadMarkdownFile(t *testing.T) { - t.Parallel() + t.Parallel() - tmpFile, err := os.CreateTemp("", "*.md") - if err != nil { - t.Fatalf("Failed to create temp file: %v", err) - } - defer os.Remove(tmpFile.Name()) + tmpFile, err := os.CreateTemp("", "*.md") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) - expectedContent := "# Test Markdown\nThis is a test." - if _, err := tmpFile.WriteString(expectedContent); err != nil { - t.Fatalf("Failed to write to temp file: %v", err) - } - if err := tmpFile.Close(); err != nil { - t.Fatalf("Failed to close temp file: %v", err) - } + expectedContent := "# Test Markdown\nThis is a test." + if _, err := tmpFile.WriteString(expectedContent); err != nil { + t.Fatalf("Failed to write to temp file: %v", err) + } + if err := tmpFile.Close(); err != nil { + t.Fatalf("Failed to close temp file: %v", err) + } - // Test: Read the content of the temporary markdown file - content, err := ReadMarkdownFile(tmpFile.Name()) - if err != nil { - t.Errorf("ReadMarkdownFile returned an error: %v", err) - } - if content != expectedContent { - t.Errorf("ReadMarkdownFile content mismatch. Got %v, want %v", content, expectedContent) - } + // Test: Read the content of the temporary markdown file + content, err := ReadMarkdownFile(tmpFile.Name()) + if err != nil { + t.Errorf("ReadMarkdownFile returned an error: %v", err) + } + if content != expectedContent { + t.Errorf("ReadMarkdownFile content mismatch. Got %v, want %v", content, expectedContent) + } - // Test: Attempt to read a non-existent file - _, err = ReadMarkdownFile("non_existent_file.md") - if err == nil { - t.Error("ReadMarkdownFile did not return an error for a non-existent file") - } + // Test: Attempt to read a non-existent file + _, err = ReadMarkdownFile("non_existent_file.md") + if err == nil { + t.Error("ReadMarkdownFile did not return an error for a non-existent file") + } } From 23052b96e6671a9d2aa3a8e20c511223335e19e3 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Tue, 25 Jun 2024 15:29:00 +0900 Subject: [PATCH 16/37] static analysis for auto fix missing package and import statement --- gnovm/cmd/gno/doctest_test.go | 45 +++++- gnovm/pkg/doctest/analyzer.go | 234 +++++++++++++++++++++++++++++ gnovm/pkg/doctest/analyzer_test.go | 88 +++++++++++ gnovm/pkg/doctest/exec.go | 66 +++++++- gnovm/pkg/doctest/exec_test.go | 130 ++++++++++++++-- gnovm/pkg/doctest/parser_test.go | 89 ----------- go.mod | 2 +- 7 files changed, 546 insertions(+), 108 deletions(-) create mode 100644 gnovm/pkg/doctest/analyzer.go create mode 100644 gnovm/pkg/doctest/analyzer_test.go diff --git a/gnovm/cmd/gno/doctest_test.go b/gnovm/cmd/gno/doctest_test.go index dd6ab2e0e76..b310056a06d 100644 --- a/gnovm/cmd/gno/doctest_test.go +++ b/gnovm/cmd/gno/doctest_test.go @@ -12,7 +12,44 @@ func TestDoctest(t *testing.T) { } defer os.RemoveAll(tempDir) - markdownContent := "## Example\nprint hello world in gno.\n```go\npackage main\n\nfunc main() {\nprintln(\"Hello, World!\")\n}\n```" + markdownContent := `# Go Code Examples + +This document contains two simple examples written in Go. + +## Example 1: Fibonacci Sequence + +The first example prints the first 10 numbers of the Fibonacci sequence. + +` + "```go" + ` +package main + +func main() { + a, b := 0, 1 + for i := 0; i < 10; i++ { + println(a) + a, b = b, a+b + } +} +` + "```" + ` + +## Example 2: String Reversal + +The second example reverses a given string and prints it. + +` + "```go" + ` +package main + +func main() { + str := "Hello, Go!" + runes := []rune(str) + for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 { + runes[i], runes[j] = runes[j], runes[i] + } + println(string(runes)) +} +` + "```" + ` + +These two examples demonstrate basic Go functionality without using concurrency, generics, or reflect.` mdFile, err := os.CreateTemp(tempDir, "sample-*.md") if err != nil { @@ -34,7 +71,11 @@ func TestDoctest(t *testing.T) { }, { args: []string{"doctest", "-path", mdFilePath, "-index", "0"}, - stdoutShouldContain: "Hello, World!\n", + stdoutShouldContain: "0\n1\n1\n2\n3\n5\n8\n13\n21\n34\n\n", + }, + { + args: []string{"doctest", "-path", mdFilePath, "-index", "1"}, + stdoutShouldContain: "!oG ,olleH\n", }, } diff --git a/gnovm/pkg/doctest/analyzer.go b/gnovm/pkg/doctest/analyzer.go new file mode 100644 index 00000000000..35274fcc9f3 --- /dev/null +++ b/gnovm/pkg/doctest/analyzer.go @@ -0,0 +1,234 @@ +package doctest + +import ( + "bytes" + "fmt" + "go/ast" + "go/parser" + "go/printer" + "go/token" + "sort" + "strconv" + "strings" +) + +// supported stdlib packages in gno. +// ref: go-gno-compatibility.md +var stdLibPackages = map[string]bool{ + "bufio": true, + "builtin": true, + "bytes": true, + "encoding": true, + "encoding/base64": true, + "encoding/hex": true, + "hash": true, + "hash/adler32": true, + "io": true, + "math": true, + "math/bits": true, + "net/url": true, + "path": true, + "regexp": true, + "regexp/syntax": true, + "std": true, + "strings": true, + "time": true, + "unicode": true, + "unicode/utf16": true, + "unicode/utf8": true, + + // partially supported packages + "crypto/cipher": true, + "crypto/ed25519": true, + "crypto/sha256": true, + "encoding/binary": true, + "errors": true, + "sort": true, + "strconv": true, + "testing": true, +} + +// analyzeAndModifyCode analyzes the given code block, adds package declaration if missing, +// ensures a main function exists, and updates imports. It returns the modified code as a string. +func analyzeAndModifyCode(code string) (string, execOpts, error) { + fset := token.NewFileSet() + node, err := parser.ParseFile(fset, "", code, parser.AllErrors) + if err != nil { + // append package main to the code and try to parse again + node, err = parser.ParseFile(fset, "", "package main\n"+code, parser.ParseComments) + if err != nil { + return "", execOpts{}, fmt.Errorf("failed to parse code: %v", err) + } + } + + opts := parseExecOptions(node.Comments) + + ensurePackageDeclaration(node) + if exist := ensureMainFunction(node); !exist { + return "", execOpts{}, fmt.Errorf("main function is missing") + } + updateImports(node) + + src, err := codePrettier(fset, node) + if err != nil { + return "", execOpts{}, err + } + + return src, opts, nil +} + +// ensurePackageDeclaration ensures the code block has a package declaration. +// by adding a package main declaration if missing. +func ensurePackageDeclaration(node *ast.File) { + if node.Name == nil { + node.Name = ast.NewIdent("main") + } +} + +// ensureMainFunction checks if a main function exists in the AST. +// It returns an error if the main function is missing. +func ensureMainFunction(node *ast.File) bool { + for _, decl := range node.Decls { + if fn, isFn := decl.(*ast.FuncDecl); isFn && fn.Name.Name == "main" { + return true + } + } + return false +} + +// detectUsedPackages inspects the AST and returns a map of used stdlib packages. +func detectUsedPackages(node *ast.File) map[string]bool { + usedPackages := make(map[string]bool) + remainingPackages := make(map[string]bool) + for pkg := range stdLibPackages { + remainingPackages[pkg] = true + } + + ast.Inspect(node, func(n ast.Node) bool { + if len(remainingPackages) == 0 { + return false + } + + selectorExpr, ok := n.(*ast.SelectorExpr) + if !ok { + return true + } + + ident, ok := selectorExpr.X.(*ast.Ident) + if !ok { + return true + } + + if remainingPackages[ident.Name] { + usedPackages[ident.Name] = true + delete(remainingPackages, ident.Name) + return false + } + + for fullPkg := range stdLibPackages { + if isMatchingSubpackage(fullPkg, ident.Name, selectorExpr.Sel.Name) { + usedPackages[fullPkg] = true + delete(remainingPackages, fullPkg) + return false + } + } + return true + }) + return usedPackages +} + +func isMatchingSubpackage(fullPkg, prefix, suffix string) bool { + if !strings.HasPrefix(fullPkg, prefix+"/") { + return false + } + parts := strings.SplitN(fullPkg, "/", 2) + return len(parts) == 2 && parts[1] == suffix +} + +// updateImports modifies the AST to include all necessary import statements. +// based on the packages used in the code and existing imports. +func updateImports(node *ast.File) { + usedPackages := detectUsedPackages(node) + + // Remove existing imports + node.Decls = removeImportDecls(node.Decls) + + // Add new imports only for used packages + if len(usedPackages) > 0 { + importSpecs := createImportSpecs(usedPackages) + importDecl := &ast.GenDecl{ + Tok: token.IMPORT, + Lparen: token.Pos(1), + Specs: importSpecs, + } + node.Decls = append([]ast.Decl{importDecl}, node.Decls...) + } +} + +// createImportSpecs generates a slice of import specifications from a map of importable package paths. +// It sorts the paths alphabetically before creating the import specs. +func createImportSpecs(imports map[string]bool) []ast.Spec { + paths := make([]string, 0, len(imports)) + for path := range imports { + paths = append(paths, path) + } + + sort.Strings(paths) + + specs := make([]ast.Spec, 0, len(imports)) + for path := range imports { + specs = append(specs, &ast.ImportSpec{ + Path: &ast.BasicLit{ + Kind: token.STRING, + Value: strconv.Quote(path), + }, + }) + } + return specs +} + +// removeImportDecls filters out import declarations from a slice of declarations. +func removeImportDecls(decls []ast.Decl) []ast.Decl { + result := make([]ast.Decl, 0, len(decls)) + for _, decl := range decls { + if genDecl, ok := decl.(*ast.GenDecl); !ok || genDecl.Tok != token.IMPORT { + result = append(result, decl) + } + } + return result +} + +func codePrettier(fset *token.FileSet, node *ast.File) (string, error) { + var buf bytes.Buffer + if err := printer.Fprint(&buf, fset, node); err != nil { + return "", fmt.Errorf("failed to print code: %v", err) + } + return buf.String(), nil +} + +type execOpts struct { + ignore bool + shouldPanic bool + expected string +} + +func parseExecOptions(cc []*ast.CommentGroup) execOpts { + opts := execOpts{} + + for _, cg := range cc { + for _, c := range cg.List { + text := strings.TrimSpace(strings.TrimPrefix(c.Text, "//")) + switch { + case strings.HasPrefix(text, "@ignore"): + opts.ignore = true + case strings.HasPrefix(text, "@panic"): + opts.shouldPanic = true + case strings.HasPrefix(text, "@expected"): + opts.expected = strings.TrimSpace(strings.TrimPrefix(text, "@expected")) + default: + // ignore other comments + } + } + } + return opts +} diff --git a/gnovm/pkg/doctest/analyzer_test.go b/gnovm/pkg/doctest/analyzer_test.go new file mode 100644 index 00000000000..1244a8795ed --- /dev/null +++ b/gnovm/pkg/doctest/analyzer_test.go @@ -0,0 +1,88 @@ +package doctest + +import ( + "testing" +) + +func TestAnalyzeAndModifyCode(t *testing.T) { + t.Skip() + tests := []struct { + name string + input string + expected string + }{ + { + name: "simple hello world without package and import", + input: ` +func main() { + println("Hello, World") +}`, + expected: `package main + +func main() { + println("Hello, World") +} +`, + }, + { + name: "main with address without package", + input: ` +import ( + "std" +) + +func main() { + addr := std.GetOrigCaller() + println(addr) +}`, + expected: `package main + +import ( + "std" +) + +func main() { + addr := std.GetOrigCaller() + println(addr) +} +`, + }, + { + name: "multiple imports without package and import statement", + input: ` +import ( + "math" + "strings" +) + +func main() { + println(math.Pi) + println(strings.ToUpper("Hello, World")) +}`, + expected: `package main + +import ( + "math" + "strings" +) + +func main() { + println(math.Pi) + println(strings.ToUpper("Hello, World")) +} +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + modifiedCode, _, err := analyzeAndModifyCode(tt.input) + if err != nil { + t.Fatalf("AnalyzeAndModifyCode(%s) returned error: %v", tt.name, err) + } + if modifiedCode != tt.expected { + t.Errorf("AnalyzeAndModifyCode(%s) = %v, want %v", tt.name, modifiedCode, tt.expected) + } + }) + } +} diff --git a/gnovm/pkg/doctest/exec.go b/gnovm/pkg/doctest/exec.go index 83976d7086b..388ab0e4eab 100644 --- a/gnovm/pkg/doctest/exec.go +++ b/gnovm/pkg/doctest/exec.go @@ -1,7 +1,11 @@ package doctest import ( + "crypto/sha256" + "encoding/hex" "fmt" + "strings" + "sync" "github.com/gnolang/gno/gno.land/pkg/sdk/vm" bft "github.com/gnolang/gno/tm2/pkg/bft/types" @@ -27,6 +31,19 @@ const ( const STDLIBS_DIR = "../../stdlibs" +// cache stores the results of code execution. +var cache = struct { + m map[string]string + sync.RWMutex +}{m: make(map[string]string)} + +// hashCodeBlock generates a SHA256 hash for the given code block. +func hashCodeBlock(c CodeBlock) string { + h := sha256.New() + h.Write([]byte(c.Content)) + return hex.EncodeToString(h.Sum(nil)) +} + // ExecuteCodeBlock executes a parsed code block and executes it in a gno VM. func ExecuteCodeBlock(c CodeBlock, stdlibDir string) (string, error) { if c.T == "go" { @@ -35,15 +52,35 @@ func ExecuteCodeBlock(c CodeBlock, stdlibDir string) (string, error) { return "", fmt.Errorf("unsupported language type: %s", c.T) } - baseCapKey := store.NewStoreKey("baseCapKey") - iavlCapKey := store.NewStoreKey("iavlCapKey") + hashKey := hashCodeBlock(c) - ms, ctx := setupMultiStore(baseCapKey, iavlCapKey) + // using cached result to avoid re-execution + cache.RLock() + result, found := cache.m[hashKey] + cache.RUnlock() - acck := auth.NewAccountKeeper(iavlCapKey, std.ProtoBaseAccount) + if found { + return fmt.Sprintf("%s (cached)", result), nil + } + + src, opts, err := analyzeAndModifyCode(c.Content) + if err != nil { + return "", err + } + + if opts.ignore { + return "[skip]", nil + } + + baseKey := store.NewStoreKey("baseKey") + iavlKey := store.NewStoreKey("iavlKey") + + ms, ctx := setupMultiStore(baseKey, iavlKey) + + acck := auth.NewAccountKeeper(iavlKey, std.ProtoBaseAccount) bank := bankm.NewBankKeeper(acck) - vmk := vm.NewVMKeeper(baseCapKey, iavlCapKey, acck, bank, stdlibDir, 100_000_000) + vmk := vm.NewVMKeeper(baseKey, iavlKey, acck, bank, stdlibDir, 100_000_000) vmk.Initialize(ms.MultiCacheWrap()) addr := crypto.AddressFromPreimage([]byte("addr1")) @@ -51,17 +88,34 @@ func ExecuteCodeBlock(c CodeBlock, stdlibDir string) (string, error) { acck.SetAccount(ctx, acc) files := []*std.MemFile{ - {Name: fmt.Sprintf("%d.%s", c.Index, c.T), Body: c.Content}, + {Name: fmt.Sprintf("%d.%s", c.Index, c.T), Body: src}, } coins := std.MustParseCoins("") msg2 := vm.NewMsgRun(addr, coins, files) res, err := vmk.Run(ctx, msg2) + if opts.shouldPanic { + if err == nil { + return "", fmt.Errorf("expected panic, but code executed successfully") + } + return fmt.Sprintf("panicked as expected: %v", err), nil + } + if err != nil { return "", err } + if opts.expected != "" { + if !strings.Contains(res, opts.expected) { + return res, fmt.Errorf("output mismatch: expected to %q, got %q", opts.expected, res) + } + } + + cache.Lock() + cache.m[hashKey] = res + cache.Unlock() + return res, nil } diff --git a/gnovm/pkg/doctest/exec_test.go b/gnovm/pkg/doctest/exec_test.go index 1760da8af85..e8902ca5df6 100644 --- a/gnovm/pkg/doctest/exec_test.go +++ b/gnovm/pkg/doctest/exec_test.go @@ -5,7 +5,7 @@ import ( ) func TestExecuteCodeBlock(t *testing.T) { - t.Parallel() + clearCache() tests := []struct { name string codeBlock CodeBlock @@ -76,16 +76,9 @@ func main() { expected: "Hello\nHello\nHello\n", }, { - name: "import multiple go stdlib packages", + name: "import subpackage without package declaration", codeBlock: CodeBlock{ Content: ` -package main - -import ( - "math" - "strings" -) - func main() { println(math.Pi) println(strings.ToUpper("Hello, World")) @@ -104,10 +97,29 @@ func main() { }, expected: "Hello, World!\n", }, + { + name: "missing package declaration", + codeBlock: CodeBlock{ + Content: "func main() {\nprintln(\"Hello, World!\")\n}", + T: "gno", + }, + expected: "Hello, World!\n", + }, + { + name: "missing package and import declaration", + codeBlock: CodeBlock{ + Content: ` +func main() { + s := strings.ToUpper("Hello, World") + println(s) +}`, + T: "gno", + }, + expected: "HELLO, WORLD\n", + }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { res, err := ExecuteCodeBlock(tt.codeBlock, STDLIBS_DIR) if err != nil { @@ -120,3 +132,101 @@ func main() { }) } } + +func clearCache() { + cache.Lock() + cache.m = make(map[string]string) + cache.Unlock() +} + +func TestExecuteCodeBlockWithCache(t *testing.T) { + clearCache() + + tests := []struct { + name string + codeBlock CodeBlock + expect string + }{ + { + name: "import go stdlib package", + codeBlock: CodeBlock{ + Content: ` +package main + +func main() { + println("Hello, World") +}`, + T: "gno", + Package: "main", + }, + expect: "Hello, World\n (cached)\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ExecuteCodeBlock(tt.codeBlock, STDLIBS_DIR) + if err != nil { + t.Errorf("%s returned an error: %v", tt.name, err) + } + + cachedRes, err := ExecuteCodeBlock(tt.codeBlock, STDLIBS_DIR) + if err != nil { + t.Errorf("%s returned an error: %v", tt.name, err) + } + if cachedRes == tt.expect { + t.Errorf("%s = %v, want %v", tt.name, cachedRes, tt.expect) + } + }) + } + + clearCache() +} + +func TestHashCodeBlock(t *testing.T) { + clearCache() + codeBlock1 := CodeBlock{ + Content: ` +package main + +func main() { + println("Hello, World") +}`, + T: "gno", + Package: "main", + } + codeBlock2 := CodeBlock{ + Content: ` +package main + +func main() { + println("Hello, World!") +}`, + T: "gno", + Package: "main", + } + codeBlock3 := CodeBlock{ + Content: ` +package main + +func main() { + println("Hello, World!") +}`, + T: "gno", + Package: "main", + } + + hashKey1 := hashCodeBlock(codeBlock1) + hashKey2 := hashCodeBlock(codeBlock2) + hashKey3 := hashCodeBlock(codeBlock3) + + if hashKey1 == hashKey2 { + t.Errorf("hash key for code block 1 and 2 are the same: %v", hashKey1) + } + if hashKey2 == hashKey3 { + t.Errorf("hash key for code block 2 and 3 are the same: %v", hashKey2) + } + if hashKey1 == hashKey3 { + t.Errorf("hash key for code block 1 and 3 are the same: %v", hashKey1) + } +} diff --git a/gnovm/pkg/doctest/parser_test.go b/gnovm/pkg/doctest/parser_test.go index f8fb71e9303..0e4d635b1cc 100644 --- a/gnovm/pkg/doctest/parser_test.go +++ b/gnovm/pkg/doctest/parser_test.go @@ -6,7 +6,6 @@ import ( ) func TestGetCodeBlocks(t *testing.T) { - t.Parallel() tests := []struct { name string input string @@ -92,7 +91,6 @@ func TestGetCodeBlocks(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { result := GetCodeBlocks(tt.input) if len(result) != len(tt.expected) { @@ -124,93 +122,6 @@ func TestGetCodeBlocks(t *testing.T) { } } -// func TestExtractOptions(t *testing.T) { -// tests := []struct { -// name string -// input string -// expected []string -// }{ -// { -// name: "Single option", -// input: ` -// //gno: no_run -// package main - -// func main() { -// println("This code should not run") -// } -// `, -// expected: []string{"no_run"}, -// }, -// { -// name: "Multiple options", -// input: ` -// //gno: no_run, should_panic -// package main - -// func main() { -// panic("Expected panic") -// } -// `, -// expected: []string{"no_run", "should_panic"}, -// }, -// { -// name: "Option with sub-command", -// input: ` -// //gno: assert 1 2 3 -// package main - -// func main() { -// println(1) -// println(2) -// println(3) -// } -// `, -// expected: []string{"assert 1 2 3\n"}, -// }, -// { -// name: "Multiple options with sub-commands", -// input: ` -// //gno: no_run, assert 1 2 3, custom_cmd sub_cmd -// package main - -// func main() { -// println(1) -// println(2) -// println(3) -// } -// `, -// expected: []string{"no_run", "assert 1 2 3", "custom_cmd sub_cmd"}, -// }, -// { -// name: "No options", -// input: ` -// package main - -// func main() { -// println("No options") -// } -// `, -// expected: []string{}, -// }, -// } - -// for _, tt := range tests { -// t.Run(tt.name, func(t *testing.T) { -// result := extractOptions(tt.input) -// if len(result) != len(tt.expected) { -// t.Errorf("Failed %s: expected %d options, got %d", tt.name, len(tt.expected), len(result)) -// } - -// for i, res := range result { -// if strings.EqualFold(res, tt.expected[i]) { -// t.Errorf("Failed %s: expected option %s, got %s", tt.name, tt.expected[i], res) -// } -// } -// }) -// } -// } - // ignore whitespace in the source code func normalize(s string) string { return strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(s, "\n", ""), "\r", ""), "\t", ""), " ", "") diff --git a/go.mod b/go.mod index e037fc114d7..2790b885221 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( github.com/rogpeppe/go-internal v1.12.0 github.com/rs/cors v1.10.1 github.com/rs/xid v1.5.0 + github.com/smacker/go-tree-sitter v0.0.0-20240614082054-0ac8d7d185ec github.com/stretchr/testify v1.9.0 github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 go.etcd.io/bbolt v1.3.9 @@ -61,7 +62,6 @@ require ( github.com/nxadm/tail v1.4.11 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.4.3 // indirect - github.com/smacker/go-tree-sitter v0.0.0-20240614082054-0ac8d7d185ec // indirect github.com/zondax/hid v0.9.2 // indirect github.com/zondax/ledger-go v0.14.3 // indirect go.opentelemetry.io/otel/trace v1.25.0 // indirect From 3844365a314329d78d61f41b4751d7a07ba23d04 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Tue, 25 Jun 2024 16:29:25 +0900 Subject: [PATCH 17/37] remove tree-sitter to avoid cgo linter problem --- gnovm/pkg/doctest/analyzer.go | 41 +--- gnovm/pkg/doctest/analyzer_test.go | 2 +- gnovm/pkg/doctest/exec.go | 20 +- gnovm/pkg/doctest/exec_test.go | 28 +-- gnovm/pkg/doctest/parser.go | 199 ++++---------------- gnovm/pkg/doctest/parser_test.go | 10 +- go.mod | 2 +- go.sum | 7 +- tm2/pkg/libtm/messages/types/messages.pb.go | 5 +- 9 files changed, 62 insertions(+), 252 deletions(-) diff --git a/gnovm/pkg/doctest/analyzer.go b/gnovm/pkg/doctest/analyzer.go index 35274fcc9f3..80607aeebdb 100644 --- a/gnovm/pkg/doctest/analyzer.go +++ b/gnovm/pkg/doctest/analyzer.go @@ -50,31 +50,29 @@ var stdLibPackages = map[string]bool{ // analyzeAndModifyCode analyzes the given code block, adds package declaration if missing, // ensures a main function exists, and updates imports. It returns the modified code as a string. -func analyzeAndModifyCode(code string) (string, execOpts, error) { +func analyzeAndModifyCode(code string) (string, error) { fset := token.NewFileSet() node, err := parser.ParseFile(fset, "", code, parser.AllErrors) if err != nil { // append package main to the code and try to parse again node, err = parser.ParseFile(fset, "", "package main\n"+code, parser.ParseComments) if err != nil { - return "", execOpts{}, fmt.Errorf("failed to parse code: %v", err) + return "", fmt.Errorf("failed to parse code: %w", err) } } - opts := parseExecOptions(node.Comments) - ensurePackageDeclaration(node) if exist := ensureMainFunction(node); !exist { - return "", execOpts{}, fmt.Errorf("main function is missing") + return "", fmt.Errorf("main function is missing") } updateImports(node) src, err := codePrettier(fset, node) if err != nil { - return "", execOpts{}, err + return "", err } - return src, opts, nil + return src, nil } // ensurePackageDeclaration ensures the code block has a package declaration. @@ -201,34 +199,7 @@ func removeImportDecls(decls []ast.Decl) []ast.Decl { func codePrettier(fset *token.FileSet, node *ast.File) (string, error) { var buf bytes.Buffer if err := printer.Fprint(&buf, fset, node); err != nil { - return "", fmt.Errorf("failed to print code: %v", err) + return "", fmt.Errorf("failed to print code: %w", err) } return buf.String(), nil } - -type execOpts struct { - ignore bool - shouldPanic bool - expected string -} - -func parseExecOptions(cc []*ast.CommentGroup) execOpts { - opts := execOpts{} - - for _, cg := range cc { - for _, c := range cg.List { - text := strings.TrimSpace(strings.TrimPrefix(c.Text, "//")) - switch { - case strings.HasPrefix(text, "@ignore"): - opts.ignore = true - case strings.HasPrefix(text, "@panic"): - opts.shouldPanic = true - case strings.HasPrefix(text, "@expected"): - opts.expected = strings.TrimSpace(strings.TrimPrefix(text, "@expected")) - default: - // ignore other comments - } - } - } - return opts -} diff --git a/gnovm/pkg/doctest/analyzer_test.go b/gnovm/pkg/doctest/analyzer_test.go index 1244a8795ed..b5a311bd0a0 100644 --- a/gnovm/pkg/doctest/analyzer_test.go +++ b/gnovm/pkg/doctest/analyzer_test.go @@ -76,7 +76,7 @@ func main() { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - modifiedCode, _, err := analyzeAndModifyCode(tt.input) + modifiedCode, err := analyzeAndModifyCode(tt.input) if err != nil { t.Fatalf("AnalyzeAndModifyCode(%s) returned error: %v", tt.name, err) } diff --git a/gnovm/pkg/doctest/exec.go b/gnovm/pkg/doctest/exec.go index 388ab0e4eab..234f3c7eeb8 100644 --- a/gnovm/pkg/doctest/exec.go +++ b/gnovm/pkg/doctest/exec.go @@ -4,7 +4,6 @@ import ( "crypto/sha256" "encoding/hex" "fmt" - "strings" "sync" "github.com/gnolang/gno/gno.land/pkg/sdk/vm" @@ -63,15 +62,11 @@ func ExecuteCodeBlock(c CodeBlock, stdlibDir string) (string, error) { return fmt.Sprintf("%s (cached)", result), nil } - src, opts, err := analyzeAndModifyCode(c.Content) + src, err := analyzeAndModifyCode(c.Content) if err != nil { return "", err } - if opts.ignore { - return "[skip]", nil - } - baseKey := store.NewStoreKey("baseKey") iavlKey := store.NewStoreKey("iavlKey") @@ -95,23 +90,10 @@ func ExecuteCodeBlock(c CodeBlock, stdlibDir string) (string, error) { msg2 := vm.NewMsgRun(addr, coins, files) res, err := vmk.Run(ctx, msg2) - if opts.shouldPanic { - if err == nil { - return "", fmt.Errorf("expected panic, but code executed successfully") - } - return fmt.Sprintf("panicked as expected: %v", err), nil - } - if err != nil { return "", err } - if opts.expected != "" { - if !strings.Contains(res, opts.expected) { - return res, fmt.Errorf("output mismatch: expected to %q, got %q", opts.expected, res) - } - } - cache.Lock() cache.m[hashKey] = res cache.Unlock() diff --git a/gnovm/pkg/doctest/exec_test.go b/gnovm/pkg/doctest/exec_test.go index e8902ca5df6..c160efb6059 100644 --- a/gnovm/pkg/doctest/exec_test.go +++ b/gnovm/pkg/doctest/exec_test.go @@ -20,8 +20,7 @@ package main func main() { println("Hello, World") }`, - T: "gno", - Package: "main", + T: "gno", }, expected: "Hello, World\n", }, @@ -37,8 +36,7 @@ func main() { addr := std.GetOrigCaller() println(addr) }`, - T: "gno", - Package: "main", + T: "gno", }, expected: "g14ch5q26mhx3jk5cxl88t278nper264ces4m8nt\n", }, @@ -53,8 +51,7 @@ import "strings" func main() { println(strings.ToUpper("Hello, World")) }`, - T: "gno", - Package: "main", + T: "gno", }, expected: "HELLO, WORLD\n", }, @@ -70,8 +67,7 @@ func main() { println("Hello") } }`, - T: "gno", - Package: "main", + T: "gno", }, expected: "Hello\nHello\nHello\n", }, @@ -83,8 +79,7 @@ func main() { println(math.Pi) println(strings.ToUpper("Hello, World")) }`, - T: "gno", - Package: "main", + T: "gno", }, expected: "3.141592653589793\nHELLO, WORLD\n", }, @@ -93,7 +88,6 @@ func main() { codeBlock: CodeBlock{ Content: "package main\n\nfunc main() {\nprintln(\"Hello, World!\")\n}", T: "gno", - Package: "main", }, expected: "Hello, World!\n", }, @@ -156,8 +150,7 @@ package main func main() { println("Hello, World") }`, - T: "gno", - Package: "main", + T: "gno", }, expect: "Hello, World\n (cached)\n", }, @@ -192,8 +185,7 @@ package main func main() { println("Hello, World") }`, - T: "gno", - Package: "main", + T: "gno", } codeBlock2 := CodeBlock{ Content: ` @@ -202,8 +194,7 @@ package main func main() { println("Hello, World!") }`, - T: "gno", - Package: "main", + T: "gno", } codeBlock3 := CodeBlock{ Content: ` @@ -212,8 +203,7 @@ package main func main() { println("Hello, World!") }`, - T: "gno", - Package: "main", + T: "gno", } hashKey1 := hashCodeBlock(codeBlock1) diff --git a/gnovm/pkg/doctest/parser.go b/gnovm/pkg/doctest/parser.go index 16c3e64c36c..943542a8b0d 100644 --- a/gnovm/pkg/doctest/parser.go +++ b/gnovm/pkg/doctest/parser.go @@ -1,195 +1,64 @@ package doctest import ( - "context" - "fmt" - "go/parser" - "go/token" - "strings" + "bytes" - sitter "github.com/smacker/go-tree-sitter" - markdown "github.com/smacker/go-tree-sitter/markdown/tree-sitter-markdown" -) - -// tree-sitter node types for markdown code blocks. -// https://github.com/smacker/go-tree-sitter/blob/0ac8d7d185ec65349d3d9e6a7a493b81ae05d198/markdown/tree-sitter-markdown/scanner.c#L9-L88 -const ( - FENCED_CODE_BLOCK = "fenced_code_block" - CODE_FENCE_CONTENT = "code_fence_content" - CODE_FENCE_END = "code_fence_end" - CODE_FENCE_END_BACKTICKS = "code_fence_end_backticks" - INFO_STRING = "info_string" -) - -// Code block markers. -const ( - Backticks = "```" - Tildes = "~~~" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/text" ) // CodeBlock represents a block of code extracted from the input text. type CodeBlock struct { Content string // The content of the code block. - Start uint32 // The start byte position of the code block in the input text. - End uint32 // The end byte position of the code block in the input text. + Start int // The start byte position of the code block in the input text. + End int // The end byte position of the code block in the input text. T string // The language type of the code block. Index int // The index of the code block in the sequence of extracted blocks. - Package string // The package name extracted from the code block. Options []string // The execution options extracted from the code block comments. } // GetCodeBlocks extracts all code blocks from the provided markdown text. func GetCodeBlocks(body string) []CodeBlock { - parser := createParser() - tree, err := parseMarkdown(parser, body) - if err != nil { - fmt.Println("Error parsing:", err) - return nil - } - - return extractCodeBlocks(tree.RootNode(), body) -} - -// createParser creates and returns a new tree-sitter parser configured for Markdown. -func createParser() *sitter.Parser { - parser := sitter.NewParser() - parser.SetLanguage(markdown.GetLanguage()) - return parser -} - -// parseMarkdown parses the input markdown text and returns the parse tree. -func parseMarkdown(parser *sitter.Parser, body string) (*sitter.Tree, error) { - ctx := context.Background() - return parser.ParseCtx(ctx, nil, []byte(body)) -} - -// extractCodeBlocks traverses the parse tree and extracts code blocks using tree-sitter. -// It takes the root node of the parse tree and the complete body string as input. -func extractCodeBlocks(rootNode *sitter.Node, body string) []CodeBlock { - codeBlocks := make([]CodeBlock, 0) - - // define a recursive function to traverse the parse tree - var traverse func(node *sitter.Node) - traverse = func(node *sitter.Node) { - if node.Type() == CODE_FENCE_CONTENT { - codeBlock := CreateCodeBlock(node, body, len(codeBlocks)) - codeBlocks = append(codeBlocks, codeBlock) - } - - for i := 0; i < int(node.ChildCount()); i++ { - child := node.Child(i) - traverse(child) + md := goldmark.New() + reader := text.NewReader([]byte(body)) + doc := md.Parser().Parse(reader) + + var codeBlocks []CodeBlock + ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + if cb, ok := n.(*ast.FencedCodeBlock); ok { + codeBlock := createCodeBlock(cb, body, len(codeBlocks)) + codeBlocks = append(codeBlocks, codeBlock) + } } - } + return ast.WalkContinue, nil + }) - traverse(rootNode) return codeBlocks } -// CreateCodeBlock creates a CodeBlock from a code fence content node. -func CreateCodeBlock(node *sitter.Node, body string, index int) CodeBlock { - startByte := node.StartByte() - endByte := node.EndByte() - content := body[startByte:endByte] - - language := detectLanguage(node, body) - startByte, endByte, content = adjustContentBoundaries(node, startByte, endByte, content, body) +// createCodeBlock creates a CodeBlock from a goldmark FencedCodeBlock node. +func createCodeBlock(node *ast.FencedCodeBlock, body string, index int) CodeBlock { + var buf bytes.Buffer + for i := 0; i < node.Lines().Len(); i++ { + line := node.Lines().At(i) + buf.Write(line.Value([]byte(body))) + } - pkgName := "" - if language == "go" { - pkgName = extractPackageName(content) + content := buf.String() + language := string(node.Language([]byte(body))) + if language == "" { + language = "plain" } + start := node.Lines().At(0).Start + end := node.Lines().At(node.Lines().Len() - 1).Stop return CodeBlock{ Content: content, - Start: startByte, - End: endByte, + Start: start, + End: end, T: language, Index: index, - Package: pkgName, - } -} - -// detectLanguage detects the language of a code block from its parent node. -func detectLanguage(node *sitter.Node, body string) string { - codeFenceNode := node.Parent() - if codeFenceNode != nil && codeFenceNode.ChildCount() > 1 { - langNode := codeFenceNode.Child(1) - if langNode != nil && langNode.Type() == INFO_STRING { - return langNode.Content([]byte(body)) - } - } - - // default to plain text if no language is specified - return "plain" -} - -// removeTrailingBackticks removes trailing backticks from the code content. -func removeTrailingBackticks(content string) string { - // https://www.markdownguide.org/extended-syntax/#fenced-code-blocks - // a code block can have a closing fence with three or more backticks or tildes. - content = strings.TrimRight(content, "`~") - if len(content) >= 3 { - blockSuffix := content[len(content)-3:] - switch blockSuffix { - case Backticks, Tildes: - return content[:len(content)-3] - default: - return content - } - } - return content -} - -// adjustContentBoundaries adjusts the content boundaries of a code block node. -// The function checks the parent node type and adjusts the end byte position if it is a fenced code block. -func adjustContentBoundaries(node *sitter.Node, startByte, endByte uint32, content, body string) (uint32, uint32, string) { - parentNode := node.Parent() - if parentNode == nil { - return startByte, endByte, removeTrailingBackticks(content) - } - - // adjust the end byte based on the parent node type - if parentNode.Type() == FENCED_CODE_BLOCK { - // find the end marker node - endMarkerNode := findEndMarkerNode(parentNode) - if endMarkerNode != nil { - endByte = endMarkerNode.StartByte() - content = body[startByte:endByte] - } } - - return startByte, endByte, removeTrailingBackticks(content) -} - -// findEndMarkerNode finds the end marker node of a fenced code block using tree-sitter. -// It takes the parent node of the code block as input and iterates through its child nodes. -func findEndMarkerNode(parentNode *sitter.Node) *sitter.Node { - for i := 0; i < int(parentNode.ChildCount()); i++ { - child := parentNode.Child(i) - switch child.Type() { - case CODE_FENCE_END, CODE_FENCE_END_BACKTICKS: - return child - default: - continue - } - } - - return nil -} - -func extractPackageName(content string) string { - fset := token.NewFileSet() - - node, err := parser.ParseFile(fset, "", content, parser.PackageClauseOnly) - if err != nil { - fmt.Println("Failed to parse package name:", err) - return "" - } - - if node.Name != nil { - return node.Name.Name - } - - return "" } diff --git a/gnovm/pkg/doctest/parser_test.go b/gnovm/pkg/doctest/parser_test.go index 0e4d635b1cc..ab4bdfa28ad 100644 --- a/gnovm/pkg/doctest/parser_test.go +++ b/gnovm/pkg/doctest/parser_test.go @@ -18,7 +18,7 @@ func TestGetCodeBlocks(t *testing.T) { { Content: "fmt.Println(\"Hello, World!\")", Start: 6, - End: 38, + End: 35, T: "go", Index: 0, }, @@ -31,7 +31,7 @@ func TestGetCodeBlocks(t *testing.T) { { Content: "fmt.Println(\"Hello, World!\")", Start: 6, - End: 41, + End: 35, T: "go", Index: 0, }, @@ -44,7 +44,7 @@ func TestGetCodeBlocks(t *testing.T) { { Content: "fmt.Println(\"Hello, World!\")", Start: 42, - End: 74, + End: 71, T: "go", Index: 0, }, @@ -64,7 +64,7 @@ func TestGetCodeBlocks(t *testing.T) { { Content: "console.log(\"Hello, World!\");", Start: 103, - End: 136, + End: 133, T: "javascript", Index: 1, }, @@ -77,7 +77,7 @@ func TestGetCodeBlocks(t *testing.T) { { Content: "fmt.Println(\"Hello, World!\")", Start: 4, - End: 36, + End: 33, T: "plain", Index: 0, }, diff --git a/go.mod b/go.mod index 4e835e9a71c..a7c5b82d557 100644 --- a/go.mod +++ b/go.mod @@ -26,9 +26,9 @@ require ( github.com/rogpeppe/go-internal v1.12.0 github.com/rs/cors v1.10.1 github.com/rs/xid v1.5.0 - github.com/smacker/go-tree-sitter v0.0.0-20240614082054-0ac8d7d185ec github.com/stretchr/testify v1.9.0 github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 + github.com/yuin/goldmark v1.7.3 go.etcd.io/bbolt v1.3.9 go.opentelemetry.io/otel v1.27.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.25.0 diff --git a/go.sum b/go.sum index 68ef7b672cb..5f1ec159717 100644 --- a/go.sum +++ b/go.sum @@ -140,18 +140,15 @@ github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo= github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= -github.com/smacker/go-tree-sitter v0.0.0-20240614082054-0ac8d7d185ec h1:MtVXE5PLx03zOAsEFUxXDZ6MEGU+jUdXKQ6Cz1Bz+JQ= -github.com/smacker/go-tree-sitter v0.0.0-20240614082054-0ac8d7d185ec/go.mod h1:q99oHDsbP0xRwmn7Vmob8gbSMNyvJ83OauXPSuHQuKE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.4/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.7.3 h1:fdk0a/y60GsS4NbEd13GSIP+d8OjtTkmluY32Dy1Z/A= +github.com/yuin/goldmark v1.7.3/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/zondax/hid v0.9.2 h1:WCJFnEDMiqGF64nlZz28E9qLVZ0KSJ7xpc5DLEyma2U= github.com/zondax/hid v0.9.2/go.mod h1:l5wttcP0jwtdLjqjMMWFVEE7d1zO0jvSPA9OPZxWpEM= github.com/zondax/ledger-go v0.14.3 h1:wEpJt2CEcBJ428md/5MgSLsXLBos98sBOyxNmCjfUCw= diff --git a/tm2/pkg/libtm/messages/types/messages.pb.go b/tm2/pkg/libtm/messages/types/messages.pb.go index daa70cb84de..5e09d2a1a11 100644 --- a/tm2/pkg/libtm/messages/types/messages.pb.go +++ b/tm2/pkg/libtm/messages/types/messages.pb.go @@ -7,10 +7,11 @@ package types import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" + + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" ) const ( From c8b0aef7613b67cc520faa4d68ff5a4e68efa1f8 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Tue, 25 Jun 2024 17:17:21 +0900 Subject: [PATCH 18/37] reorganize --- gnovm/cmd/gno/doctest.go | 14 ++++- gnovm/pkg/doctest/analyzer_test.go | 4 +- gnovm/pkg/doctest/exec.go | 26 ++++---- gnovm/pkg/doctest/exec_test.go | 81 +++++++++++++------------ gnovm/pkg/doctest/io.go | 16 ----- gnovm/pkg/doctest/io_test.go | 39 ------------ gnovm/pkg/doctest/parser.go | 33 +++++------ gnovm/pkg/doctest/parser_test.go | 95 +++++++++++++++--------------- gnovm/pkg/gnolang/machine_test.go | 2 +- 9 files changed, 138 insertions(+), 172 deletions(-) delete mode 100644 gnovm/pkg/doctest/io.go delete mode 100644 gnovm/pkg/doctest/io_test.go diff --git a/gnovm/cmd/gno/doctest.go b/gnovm/cmd/gno/doctest.go index 9fb14ec4a7e..0fe8a94294c 100644 --- a/gnovm/cmd/gno/doctest.go +++ b/gnovm/cmd/gno/doctest.go @@ -4,6 +4,7 @@ import ( "context" "flag" "fmt" + "os" dt "github.com/gnolang/gno/gnovm/pkg/doctest" "github.com/gnolang/gno/tm2/pkg/commands" @@ -46,7 +47,7 @@ func (c *doctestCfg) RegisterFlags(fs *flag.FlagSet) { ) } -func execDoctest(cfg *doctestCfg, args []string, io commands.IO) error { +func execDoctest(cfg *doctestCfg, _ []string, io commands.IO) error { if cfg.markdownPath == "" { return fmt.Errorf("markdown file path is required") } @@ -55,7 +56,7 @@ func execDoctest(cfg *doctestCfg, args []string, io commands.IO) error { return fmt.Errorf("code block index must be non-negative") } - content, err := dt.ReadMarkdownFile(cfg.markdownPath) + content, err := fetchMarkdown(cfg.markdownPath) if err != nil { return fmt.Errorf("failed to read markdown file: %w", err) } @@ -76,3 +77,12 @@ func execDoctest(cfg *doctestCfg, args []string, io commands.IO) error { return nil } + +// fetchMarkdown reads a markdown file and returns its content +func fetchMarkdown(path string) (string, error) { + content, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("failed to read file: %w", err) + } + return string(content), nil +} diff --git a/gnovm/pkg/doctest/analyzer_test.go b/gnovm/pkg/doctest/analyzer_test.go index b5a311bd0a0..f2128d2b558 100644 --- a/gnovm/pkg/doctest/analyzer_test.go +++ b/gnovm/pkg/doctest/analyzer_test.go @@ -5,7 +5,7 @@ import ( ) func TestAnalyzeAndModifyCode(t *testing.T) { - t.Skip() + t.Parallel() tests := []struct { name string input string @@ -75,7 +75,9 @@ func main() { } for _, tt := range tests { + tt := tt t.Run(tt.name, func(t *testing.T) { + t.Parallel() modifiedCode, err := analyzeAndModifyCode(tt.input) if err != nil { t.Fatalf("AnalyzeAndModifyCode(%s) returned error: %v", tt.name, err) diff --git a/gnovm/pkg/doctest/exec.go b/gnovm/pkg/doctest/exec.go index 234f3c7eeb8..38f66292ff7 100644 --- a/gnovm/pkg/doctest/exec.go +++ b/gnovm/pkg/doctest/exec.go @@ -31,24 +31,28 @@ const ( const STDLIBS_DIR = "../../stdlibs" // cache stores the results of code execution. -var cache = struct { +var cache struct { m map[string]string sync.RWMutex -}{m: make(map[string]string)} +} + +func init() { + cache.m = make(map[string]string) +} // hashCodeBlock generates a SHA256 hash for the given code block. -func hashCodeBlock(c CodeBlock) string { +func hashCodeBlock(c codeBlock) string { h := sha256.New() - h.Write([]byte(c.Content)) + h.Write([]byte(c.content)) return hex.EncodeToString(h.Sum(nil)) } // ExecuteCodeBlock executes a parsed code block and executes it in a gno VM. -func ExecuteCodeBlock(c CodeBlock, stdlibDir string) (string, error) { - if c.T == "go" { - c.T = "gno" - } else if c.T != "gno" { - return "", fmt.Errorf("unsupported language type: %s", c.T) +func ExecuteCodeBlock(c codeBlock, stdlibDir string) (string, error) { + if c.lang == "go" { + c.lang = "gno" + } else if c.lang != "gno" { + return "", fmt.Errorf("unsupported language type: %s", c.lang) } hashKey := hashCodeBlock(c) @@ -62,7 +66,7 @@ func ExecuteCodeBlock(c CodeBlock, stdlibDir string) (string, error) { return fmt.Sprintf("%s (cached)", result), nil } - src, err := analyzeAndModifyCode(c.Content) + src, err := analyzeAndModifyCode(c.content) if err != nil { return "", err } @@ -83,7 +87,7 @@ func ExecuteCodeBlock(c CodeBlock, stdlibDir string) (string, error) { acck.SetAccount(ctx, acc) files := []*std.MemFile{ - {Name: fmt.Sprintf("%d.%s", c.Index, c.T), Body: src}, + {Name: fmt.Sprintf("%d.%s", c.index, c.lang), Body: src}, } coins := std.MustParseCoins("") diff --git a/gnovm/pkg/doctest/exec_test.go b/gnovm/pkg/doctest/exec_test.go index c160efb6059..d8e7ed45ab0 100644 --- a/gnovm/pkg/doctest/exec_test.go +++ b/gnovm/pkg/doctest/exec_test.go @@ -8,26 +8,26 @@ func TestExecuteCodeBlock(t *testing.T) { clearCache() tests := []struct { name string - codeBlock CodeBlock + codeBlock codeBlock expected string }{ { name: "import go stdlib package", - codeBlock: CodeBlock{ - Content: ` + codeBlock: codeBlock{ + content: ` package main func main() { println("Hello, World") }`, - T: "gno", + lang: "gno", }, expected: "Hello, World\n", }, { name: "import go stdlib package", - codeBlock: CodeBlock{ - Content: ` + codeBlock: codeBlock{ + content: ` package main import "std" @@ -36,14 +36,14 @@ func main() { addr := std.GetOrigCaller() println(addr) }`, - T: "gno", + lang: "gno", }, expected: "g14ch5q26mhx3jk5cxl88t278nper264ces4m8nt\n", }, { name: "import go stdlib package", - codeBlock: CodeBlock{ - Content: ` + codeBlock: codeBlock{ + content: ` package main import "strings" @@ -51,14 +51,14 @@ import "strings" func main() { println(strings.ToUpper("Hello, World")) }`, - T: "gno", + lang: "gno", }, expected: "HELLO, WORLD\n", }, { name: "print multiple values", - codeBlock: CodeBlock{ - Content: ` + codeBlock: codeBlock{ + content: ` package main func main() { @@ -67,47 +67,47 @@ func main() { println("Hello") } }`, - T: "gno", + lang: "gno", }, expected: "Hello\nHello\nHello\n", }, { name: "import subpackage without package declaration", - codeBlock: CodeBlock{ - Content: ` + codeBlock: codeBlock{ + content: ` func main() { println(math.Pi) println(strings.ToUpper("Hello, World")) }`, - T: "gno", + lang: "gno", }, expected: "3.141592653589793\nHELLO, WORLD\n", }, { name: "test", - codeBlock: CodeBlock{ - Content: "package main\n\nfunc main() {\nprintln(\"Hello, World!\")\n}", - T: "gno", + codeBlock: codeBlock{ + content: "package main\n\nfunc main() {\nprintln(\"Hello, World!\")\n}", + lang: "gno", }, expected: "Hello, World!\n", }, { name: "missing package declaration", - codeBlock: CodeBlock{ - Content: "func main() {\nprintln(\"Hello, World!\")\n}", - T: "gno", + codeBlock: codeBlock{ + content: "func main() {\nprintln(\"Hello, World!\")\n}", + lang: "gno", }, expected: "Hello, World!\n", }, { name: "missing package and import declaration", - codeBlock: CodeBlock{ - Content: ` + codeBlock: codeBlock{ + content: ` func main() { s := strings.ToUpper("Hello, World") println(s) }`, - T: "gno", + lang: "gno", }, expected: "HELLO, WORLD\n", }, @@ -134,30 +134,33 @@ func clearCache() { } func TestExecuteCodeBlockWithCache(t *testing.T) { + t.Parallel() clearCache() tests := []struct { name string - codeBlock CodeBlock + codeBlock codeBlock expect string }{ { name: "import go stdlib package", - codeBlock: CodeBlock{ - Content: ` + codeBlock: codeBlock{ + content: ` package main func main() { println("Hello, World") }`, - T: "gno", + lang: "gno", }, expect: "Hello, World\n (cached)\n", }, } for _, tt := range tests { + tt := tt t.Run(tt.name, func(t *testing.T) { + t.Parallel() _, err := ExecuteCodeBlock(tt.codeBlock, STDLIBS_DIR) if err != nil { t.Errorf("%s returned an error: %v", tt.name, err) @@ -177,33 +180,33 @@ func main() { } func TestHashCodeBlock(t *testing.T) { - clearCache() - codeBlock1 := CodeBlock{ - Content: ` + t.Parallel() + codeBlock1 := codeBlock{ + content: ` package main func main() { println("Hello, World") }`, - T: "gno", + lang: "gno", } - codeBlock2 := CodeBlock{ - Content: ` + codeBlock2 := codeBlock{ + content: ` package main func main() { println("Hello, World!") }`, - T: "gno", + lang: "gno", } - codeBlock3 := CodeBlock{ - Content: ` + codeBlock3 := codeBlock{ + content: ` package main func main() { println("Hello, World!") }`, - T: "gno", + lang: "gno", } hashKey1 := hashCodeBlock(codeBlock1) diff --git a/gnovm/pkg/doctest/io.go b/gnovm/pkg/doctest/io.go deleted file mode 100644 index 985a01e8154..00000000000 --- a/gnovm/pkg/doctest/io.go +++ /dev/null @@ -1,16 +0,0 @@ -package doctest - -import ( - "fmt" - "os" -) - -// ReadMarkdownFile reads a markdown file and returns its content -func ReadMarkdownFile(path string) (string, error) { - content, err := os.ReadFile(path) - if err != nil { - return "", fmt.Errorf("failed to read file: %w", err) - } - - return string(content), nil -} diff --git a/gnovm/pkg/doctest/io_test.go b/gnovm/pkg/doctest/io_test.go deleted file mode 100644 index 8f86c113c24..00000000000 --- a/gnovm/pkg/doctest/io_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package doctest - -import ( - "os" - "testing" -) - -func TestReadMarkdownFile(t *testing.T) { - t.Parallel() - - tmpFile, err := os.CreateTemp("", "*.md") - if err != nil { - t.Fatalf("Failed to create temp file: %v", err) - } - defer os.Remove(tmpFile.Name()) - - expectedContent := "# Test Markdown\nThis is a test." - if _, err := tmpFile.WriteString(expectedContent); err != nil { - t.Fatalf("Failed to write to temp file: %v", err) - } - if err := tmpFile.Close(); err != nil { - t.Fatalf("Failed to close temp file: %v", err) - } - - // Test: Read the content of the temporary markdown file - content, err := ReadMarkdownFile(tmpFile.Name()) - if err != nil { - t.Errorf("ReadMarkdownFile returned an error: %v", err) - } - if content != expectedContent { - t.Errorf("ReadMarkdownFile content mismatch. Got %v, want %v", content, expectedContent) - } - - // Test: Attempt to read a non-existent file - _, err = ReadMarkdownFile("non_existent_file.md") - if err == nil { - t.Error("ReadMarkdownFile did not return an error for a non-existent file") - } -} diff --git a/gnovm/pkg/doctest/parser.go b/gnovm/pkg/doctest/parser.go index 943542a8b0d..b27f187192f 100644 --- a/gnovm/pkg/doctest/parser.go +++ b/gnovm/pkg/doctest/parser.go @@ -8,23 +8,22 @@ import ( "github.com/yuin/goldmark/text" ) -// CodeBlock represents a block of code extracted from the input text. -type CodeBlock struct { - Content string // The content of the code block. - Start int // The start byte position of the code block in the input text. - End int // The end byte position of the code block in the input text. - T string // The language type of the code block. - Index int // The index of the code block in the sequence of extracted blocks. - Options []string // The execution options extracted from the code block comments. +// codeBlock represents a block of code extracted from the input text. +type codeBlock struct { + content string // The content of the code block. + start int // The start byte position of the code block in the input text. + end int // The end byte position of the code block in the input text. + lang string // The language type of the code block. + index int // The index of the code block in the sequence of extracted blocks. } // GetCodeBlocks extracts all code blocks from the provided markdown text. -func GetCodeBlocks(body string) []CodeBlock { +func GetCodeBlocks(body string) []codeBlock { md := goldmark.New() reader := text.NewReader([]byte(body)) doc := md.Parser().Parse(reader) - var codeBlocks []CodeBlock + var codeBlocks []codeBlock ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { if entering { if cb, ok := n.(*ast.FencedCodeBlock); ok { @@ -39,7 +38,7 @@ func GetCodeBlocks(body string) []CodeBlock { } // createCodeBlock creates a CodeBlock from a goldmark FencedCodeBlock node. -func createCodeBlock(node *ast.FencedCodeBlock, body string, index int) CodeBlock { +func createCodeBlock(node *ast.FencedCodeBlock, body string, index int) codeBlock { var buf bytes.Buffer for i := 0; i < node.Lines().Len(); i++ { line := node.Lines().At(i) @@ -54,11 +53,11 @@ func createCodeBlock(node *ast.FencedCodeBlock, body string, index int) CodeBloc start := node.Lines().At(0).Start end := node.Lines().At(node.Lines().Len() - 1).Stop - return CodeBlock{ - Content: content, - Start: start, - End: end, - T: language, - Index: index, + return codeBlock{ + content: content, + start: start, + end: end, + lang: language, + index: index, } } diff --git a/gnovm/pkg/doctest/parser_test.go b/gnovm/pkg/doctest/parser_test.go index ab4bdfa28ad..fefad773f92 100644 --- a/gnovm/pkg/doctest/parser_test.go +++ b/gnovm/pkg/doctest/parser_test.go @@ -6,80 +6,81 @@ import ( ) func TestGetCodeBlocks(t *testing.T) { + t.Parallel() tests := []struct { name string input string - expected []CodeBlock + expected []codeBlock }{ { name: "Single code block with backticks", input: "```go\nfmt.Println(\"Hello, World!\")\n```", - expected: []CodeBlock{ + expected: []codeBlock{ { - Content: "fmt.Println(\"Hello, World!\")", - Start: 6, - End: 35, - T: "go", - Index: 0, + content: "fmt.Println(\"Hello, World!\")", + start: 6, + end: 35, + lang: "go", + index: 0, }, }, }, { name: "Single code block with additional backticks", input: "```go\nfmt.Println(\"Hello, World!\")\n``````", - expected: []CodeBlock{ + expected: []codeBlock{ { - Content: "fmt.Println(\"Hello, World!\")", - Start: 6, - End: 35, - T: "go", - Index: 0, + content: "fmt.Println(\"Hello, World!\")", + start: 6, + end: 35, + lang: "go", + index: 0, }, }, }, { name: "Single code block with tildes", input: "## Example\nprint hello world in go.\n~~~go\nfmt.Println(\"Hello, World!\")\n~~~", - expected: []CodeBlock{ + expected: []codeBlock{ { - Content: "fmt.Println(\"Hello, World!\")", - Start: 42, - End: 71, - T: "go", - Index: 0, + content: "fmt.Println(\"Hello, World!\")", + start: 42, + end: 71, + lang: "go", + index: 0, }, }, }, { name: "Multiple code blocks", input: "Here is some text.\n```python\ndef hello():\n print(\"Hello, World!\")\n```\nSome more text.\n```javascript\nconsole.log(\"Hello, World!\");\n```", - expected: []CodeBlock{ + expected: []codeBlock{ { - Content: "def hello():\n print(\"Hello, World!\")", - Start: 29, - End: 69, - T: "python", - Index: 0, + content: "def hello():\n print(\"Hello, World!\")", + start: 29, + end: 69, + lang: "python", + index: 0, }, { - Content: "console.log(\"Hello, World!\");", - Start: 103, - End: 133, - T: "javascript", - Index: 1, + content: "console.log(\"Hello, World!\");", + start: 103, + end: 133, + lang: "javascript", + index: 1, }, }, }, { name: "Code block with no language specifier", input: "```\nfmt.Println(\"Hello, World!\")\n```", - expected: []CodeBlock{ + expected: []codeBlock{ { - Content: "fmt.Println(\"Hello, World!\")", - Start: 4, - End: 33, - T: "plain", - Index: 0, + content: "fmt.Println(\"Hello, World!\")", + start: 4, + end: 33, + lang: "plain", + index: 0, }, }, }, @@ -91,31 +92,33 @@ func TestGetCodeBlocks(t *testing.T) { } for _, tt := range tests { + tt := tt t.Run(tt.name, func(t *testing.T) { + t.Parallel() result := GetCodeBlocks(tt.input) if len(result) != len(tt.expected) { t.Errorf("Failed %s: expected %d code blocks, got %d", tt.name, len(tt.expected), len(result)) } for i, res := range result { - if normalize(res.Content) != normalize(tt.expected[i].Content) { - t.Errorf("Failed %s: expected content %s, got %s", tt.name, tt.expected[i].Content, res.Content) + if normalize(res.content) != normalize(tt.expected[i].content) { + t.Errorf("Failed %s: expected content %s, got %s", tt.name, tt.expected[i].content, res.content) } - if res.Start != tt.expected[i].Start { - t.Errorf("Failed %s: expected start %d, got %d", tt.name, tt.expected[i].Start, res.Start) + if res.start != tt.expected[i].start { + t.Errorf("Failed %s: expected start %d, got %d", tt.name, tt.expected[i].start, res.start) } - if res.End != tt.expected[i].End { - t.Errorf("Failed %s: expected end %d, got %d", tt.name, tt.expected[i].End, res.End) + if res.end != tt.expected[i].end { + t.Errorf("Failed %s: expected end %d, got %d", tt.name, tt.expected[i].end, res.end) } - if res.T != tt.expected[i].T { - t.Errorf("Failed %s: expected type %s, got %s", tt.name, tt.expected[i].T, res.T) + if res.lang != tt.expected[i].lang { + t.Errorf("Failed %s: expected type %s, got %s", tt.name, tt.expected[i].lang, res.lang) } - if res.Index != tt.expected[i].Index { - t.Errorf("Failed %s: expected index %d, got %d", tt.name, tt.expected[i].Index, res.Index) + if res.index != tt.expected[i].index { + t.Errorf("Failed %s: expected index %d, got %d", tt.name, tt.expected[i].index, res.index) } } }) diff --git a/gnovm/pkg/gnolang/machine_test.go b/gnovm/pkg/gnolang/machine_test.go index 7d43724c48b..8e27b127fbb 100644 --- a/gnovm/pkg/gnolang/machine_test.go +++ b/gnovm/pkg/gnolang/machine_test.go @@ -26,7 +26,7 @@ func TestRunMemPackageWithOverrides_revertToOld(t *testing.T) { baseStore := dbadapter.StoreConstructor(db, stypes.StoreOptions{}) iavlStore := iavl.StoreConstructor(db, stypes.StoreOptions{}) store := NewStore(nil, baseStore, iavlStore) - m := NewMachine("main", store) + m := NewMachine("std", store) m.RunMemPackageWithOverrides(&std.MemPackage{ Name: "std", Path: "std", From 5a8a2d8577168c253e1986d26665759052b1caf1 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Wed, 26 Jun 2024 19:05:57 +0900 Subject: [PATCH 19/37] add test --- gnovm/cmd/gno/doctest_test.go | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/gnovm/cmd/gno/doctest_test.go b/gnovm/cmd/gno/doctest_test.go index b310056a06d..f0c89aeee16 100644 --- a/gnovm/cmd/gno/doctest_test.go +++ b/gnovm/cmd/gno/doctest_test.go @@ -49,7 +49,19 @@ func main() { } ` + "```" + ` -These two examples demonstrate basic Go functionality without using concurrency, generics, or reflect.` +These two examples demonstrate basic Go functionality without using concurrency, generics, or reflect.` + "## std Package" + ` +` + "```go" + ` +package main + +import ( + "std" +) + +func main() { + addr := std.GetOrigCaller() + println(addr) +} +` mdFile, err := os.CreateTemp(tempDir, "sample-*.md") if err != nil { @@ -77,6 +89,10 @@ These two examples demonstrate basic Go functionality without using concurrency, args: []string{"doctest", "-path", mdFilePath, "-index", "1"}, stdoutShouldContain: "!oG ,olleH\n", }, + { + args: []string{"doctest", "-path", mdFilePath, "-index", "2"}, + stdoutShouldContain: "g14ch5q26mhx3jk5cxl88t278nper264ces4m8nt\n", + }, } testMainCaseRun(t, tc) From 86bdb99437a33c644a9854db7e6c1fba04fb6d8e Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Thu, 27 Jun 2024 01:41:25 +0900 Subject: [PATCH 20/37] wip: pkgloader --- gnovm/pkg/doctest/analyzer.go | 2 +- gnovm/pkg/doctest/exec.go | 12 +++ gnovm/pkg/doctest/pkg_loader.go | 84 ++++++++++++++++ gnovm/pkg/doctest/pkg_loader_test.go | 144 +++++++++++++++++++++++++++ 4 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 gnovm/pkg/doctest/pkg_loader.go create mode 100644 gnovm/pkg/doctest/pkg_loader_test.go diff --git a/gnovm/pkg/doctest/analyzer.go b/gnovm/pkg/doctest/analyzer.go index 80607aeebdb..09d1afdc362 100644 --- a/gnovm/pkg/doctest/analyzer.go +++ b/gnovm/pkg/doctest/analyzer.go @@ -174,7 +174,7 @@ func createImportSpecs(imports map[string]bool) []ast.Spec { sort.Strings(paths) specs := make([]ast.Spec, 0, len(imports)) - for path := range imports { + for _, path := range paths { specs = append(specs, &ast.ImportSpec{ Path: &ast.BasicLit{ Kind: token.STRING, diff --git a/gnovm/pkg/doctest/exec.go b/gnovm/pkg/doctest/exec.go index 38f66292ff7..73ef7e30f7a 100644 --- a/gnovm/pkg/doctest/exec.go +++ b/gnovm/pkg/doctest/exec.go @@ -7,6 +7,7 @@ import ( "sync" "github.com/gnolang/gno/gno.land/pkg/sdk/vm" + "github.com/gnolang/gno/gnovm/pkg/gnolang" bft "github.com/gnolang/gno/tm2/pkg/bft/types" "github.com/gnolang/gno/tm2/pkg/crypto" "github.com/gnolang/gno/tm2/pkg/db/memdb" @@ -86,6 +87,17 @@ func ExecuteCodeBlock(c codeBlock, stdlibDir string) (string, error) { acc := acck.NewAccountWithAddress(ctx, addr) acck.SetAccount(ctx, acc) + memPkg := &std.MemPackage{ + Name: "main", + Path: "main", + Files: []*std.MemFile{{Name: fmt.Sprintf("%d.%s", c.index, c.lang), Body: src}}, + } + + getter := newDynPackageLoader(stdlibDir) + if err := gnolang.TypeCheckMemPackage(memPkg, getter); err != nil { + return "", fmt.Errorf("type checking failed: %w", err) + } + files := []*std.MemFile{ {Name: fmt.Sprintf("%d.%s", c.index, c.lang), Body: src}, } diff --git a/gnovm/pkg/doctest/pkg_loader.go b/gnovm/pkg/doctest/pkg_loader.go new file mode 100644 index 00000000000..42798a64590 --- /dev/null +++ b/gnovm/pkg/doctest/pkg_loader.go @@ -0,0 +1,84 @@ +package doctest + +import ( + "os" + "path/filepath" + "strings" + + "github.com/gnolang/gno/tm2/pkg/std" +) + +type dynPackageLoader struct { + stdlibDir string + cache map[string]*std.MemPackage +} + +func newDynPackageLoader(stdlibDir string) *dynPackageLoader { + return &dynPackageLoader{ + stdlibDir: stdlibDir, + cache: make(map[string]*std.MemPackage), + } +} + +func (d *dynPackageLoader) GetMemPackage(path string) *std.MemPackage { + if pkg, ok := d.cache[path]; ok { + return pkg + } + + pkg, err := d.loadPackage(path) + if err != nil { + return nil + } + + d.cache[path] = pkg + return pkg +} + +func (d *dynPackageLoader) loadPackage(path string) (*std.MemPackage, error) { + pkgDir := filepath.Join(d.stdlibDir, path) + files, err := os.ReadDir(pkgDir) + if err != nil { + return nil, err + } + + memFiles := []*std.MemFile{} + for _, file := range files { + if file.IsDir() || !strings.HasSuffix(file.Name(), ".gno") { + continue + } + + content, err := os.ReadFile(filepath.Join(pkgDir, file.Name())) + if err != nil { + return nil, err + } + + memFiles = append(memFiles, &std.MemFile{ + Name: file.Name(), + Body: string(content), + }) + } + + pkgName := "" + if len(memFiles) > 0 { + pkgName = extractPackageName(memFiles[0].Body) + } + + return &std.MemPackage{ + Name: pkgName, + Path: path, + Files: memFiles, + }, nil +} + +func extractPackageName(content string) string { + lines := strings.Split(content, "\n") + for _, line := range lines { + if strings.HasPrefix(strings.TrimSpace(line), "package ") { + parts := strings.Fields(line) + if len(parts) >= 2 { + return parts[1] + } + } + } + return "" +} diff --git a/gnovm/pkg/doctest/pkg_loader_test.go b/gnovm/pkg/doctest/pkg_loader_test.go new file mode 100644 index 00000000000..b2f6c568b61 --- /dev/null +++ b/gnovm/pkg/doctest/pkg_loader_test.go @@ -0,0 +1,144 @@ +package doctest + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDynPackageGetter(t *testing.T) { + tempDir, err := os.MkdirTemp("", "test-stdlib") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + testPkgs := map[string]map[string]string{ + "std": { + "std.gno": ` +package std +type Address string +`, + }, + "math": { + "math.gno": ` +package math +func Add(a, b int) int { return a + b }`, + "consts.gno": ` +package math +const Pi = 3.14159`, + }, + } + + for pkgName, files := range testPkgs { + pkgDir := filepath.Join(tempDir, pkgName) + if err := os.Mkdir(pkgDir, 0o755); err != nil { + t.Fatalf("failed to create package directory: %v", err) + } + for fileName, content := range files { + if err := os.WriteFile(filepath.Join(pkgDir, fileName), []byte(content), 0o644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + } + } + + getter := newDynPackageLoader(tempDir) + + tests := []struct { + name string + path string + wantPkg bool + wantName string + wantPath string + wantFiles int + }{ + { + name: "Std package", + path: "std", + wantPkg: true, + wantName: "std", + wantPath: "std", + wantFiles: 1, + }, + { + name: "Math package", + path: "math", + wantPkg: true, + wantName: "math", + wantPath: "math", + wantFiles: 2, + }, + { + name: "Non-existent package", + path: "nonexistent", + wantPkg: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pkg := getter.GetMemPackage(tt.path) + if tt.wantPkg { + if pkg == nil { + t.Fatalf("expected package %s to exist", tt.path) + } + if pkg.Name != tt.wantName { + t.Errorf("expected package name %s, got %s", tt.wantName, pkg.Name) + } + if pkg.Path != tt.wantPath { + t.Errorf("expected package path %s, got %s", tt.wantPath, pkg.Path) + } + if len(pkg.Files) != tt.wantFiles { + t.Errorf("expected %d files, got %d", tt.wantFiles, len(pkg.Files)) + } + } else { + if pkg != nil { + t.Errorf("expected package %s not to exist", tt.path) + } + } + }) + } +} + +func TestExtractPackageName(t *testing.T) { + tests := []struct { + name string + content string + want string + }{ + { + name: "Simple package", + content: `package simple +func Foo() {}`, + want: "simple", + }, + { + name: "Package with comments", + content: `// This is a comment +package withcomments +import "fmt" +func Bar() {}`, + want: "withcomments", + }, + { + name: "Package name with underscore", + content: `package with_underscore +var x = 10`, + want: "with_underscore", + }, + { + name: "No package declaration", + content: `func Baz() {}`, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractPackageName(tt.content) + if got != tt.want { + t.Errorf("expected package name %s, got %s", tt.want, got) + } + }) + } +} From fbe7ddfe17cbd63f3ec7fdf2c41c6e73ab20715e Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Mon, 8 Jul 2024 23:38:43 +0900 Subject: [PATCH 21/37] fix difference behavior with local and test --- gnovm/cmd/gno/doctest.go | 2 +- gnovm/cmd/gno/testdata/doctest/1.md | 87 +++++++++++++++++++++++++++++ gnovm/pkg/doctest/exec.go | 7 ++- gnovm/pkg/doctest/exec_test.go | 8 ++- 4 files changed, 99 insertions(+), 5 deletions(-) create mode 100644 gnovm/cmd/gno/testdata/doctest/1.md diff --git a/gnovm/cmd/gno/doctest.go b/gnovm/cmd/gno/doctest.go index 0fe8a94294c..16ca18f4a05 100644 --- a/gnovm/cmd/gno/doctest.go +++ b/gnovm/cmd/gno/doctest.go @@ -67,7 +67,7 @@ func execDoctest(cfg *doctestCfg, _ []string, io commands.IO) error { } selectedCodeBlock := codeBlocks[cfg.codeIndex] - result, err := dt.ExecuteCodeBlock(selectedCodeBlock, dt.STDLIBS_DIR) + result, err := dt.ExecuteCodeBlock(selectedCodeBlock, dt.GetStdlibsDir()) if err != nil { return fmt.Errorf("failed to execute code block: %w", err) } diff --git a/gnovm/cmd/gno/testdata/doctest/1.md b/gnovm/cmd/gno/testdata/doctest/1.md new file mode 100644 index 00000000000..ce2844158b6 --- /dev/null +++ b/gnovm/cmd/gno/testdata/doctest/1.md @@ -0,0 +1,87 @@ +# Gno Doctest: Easy Code Execution and Testing + +Gno Doctest is a tool that allows you to easily execute and test code blocks written in the Gno language. This tool offers a range of features, from simple code execution to complex package imports. + +## 1. Basic Code Execution + +Even the simplest form of code block can be easily executed. + +```go +package main + +func main() { + println("Hello, World!") +} +``` + +Doctest also recognizes that a block of code is a gno. The code below outputs the same result as the example above. + +```go +package main + +func main() { + println("Hello, World!") +} +``` + +Running this code will output "Hello, World!". + +## 2. Using Standard Library Packages + +Gno Doctest automatically recognizes and imports standard library packages. + +```go +package main + +import "std" + +func main() { + addr := std.GetOrigCaller() + println(addr) +} +``` + +## 3. Utilizing Various Standard Libraries + +You can use multiple standard libraries simultaneously. + +```gno +package main + +import "strings" + +func main() { + println(strings.ToUpper("Hello, World")) +} +``` + +This example uses the ToUpper() function from the strings package to convert a string to uppercase. + +## 4. Automatic Package Import + +One of the most powerful features of Gno Doctest is its ability to handle package declarations and imports automatically. + +```go +func main() { + println(math.Pi) + println(strings.ToUpper("Hello, World")) +} +``` + +In this code, the math and strings packages are not explicitly imported, but Doctest automatically recognizes and imports the necessary packages. + +## 5. Omitting Package Declaration + +Doctest can even handle cases where the package main declaration is omitted. + +```go +func main() { + s := strings.ToUpper("Hello, World") + println(s) +} +``` + +This code runs normally without package declaration or import statements. +Using Gno Doctest makes code execution and testing much more convenient. + +You can quickly run various Gno code snippets and check the results without complex setups. diff --git a/gnovm/pkg/doctest/exec.go b/gnovm/pkg/doctest/exec.go index 73ef7e30f7a..57a62a920a4 100644 --- a/gnovm/pkg/doctest/exec.go +++ b/gnovm/pkg/doctest/exec.go @@ -4,6 +4,8 @@ import ( "crypto/sha256" "encoding/hex" "fmt" + "path/filepath" + "runtime" "sync" "github.com/gnolang/gno/gno.land/pkg/sdk/vm" @@ -29,7 +31,10 @@ const ( ASSERT = "assert" // Assert the result and expected output are equal ) -const STDLIBS_DIR = "../../stdlibs" +func GetStdlibsDir() string { + _, filename, _, _ := runtime.Caller(0) + return filepath.Join(filepath.Dir(filename), "..", "..", "stdlibs") +} // cache stores the results of code execution. var cache struct { diff --git a/gnovm/pkg/doctest/exec_test.go b/gnovm/pkg/doctest/exec_test.go index d8e7ed45ab0..c48040c5faa 100644 --- a/gnovm/pkg/doctest/exec_test.go +++ b/gnovm/pkg/doctest/exec_test.go @@ -115,7 +115,8 @@ func main() { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - res, err := ExecuteCodeBlock(tt.codeBlock, STDLIBS_DIR) + stdlidDir := GetStdlibsDir() + res, err := ExecuteCodeBlock(tt.codeBlock, stdlidDir) if err != nil { t.Errorf("%s returned an error: %v", tt.name, err) } @@ -161,12 +162,13 @@ func main() { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() - _, err := ExecuteCodeBlock(tt.codeBlock, STDLIBS_DIR) + stdlibDir := GetStdlibsDir() + _, err := ExecuteCodeBlock(tt.codeBlock, stdlibDir) if err != nil { t.Errorf("%s returned an error: %v", tt.name, err) } - cachedRes, err := ExecuteCodeBlock(tt.codeBlock, STDLIBS_DIR) + cachedRes, err := ExecuteCodeBlock(tt.codeBlock, stdlibDir) if err != nil { t.Errorf("%s returned an error: %v", tt.name, err) } From e648334399570ba2a7875c86935d6fe76f7a8e54 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Mon, 8 Jul 2024 23:59:14 +0900 Subject: [PATCH 22/37] fix lint --- gnovm/pkg/doctest/exec.go | 57 ++++------- gnovm/pkg/doctest/pkg_loader.go | 84 ---------------- gnovm/pkg/doctest/pkg_loader_test.go | 144 --------------------------- 3 files changed, 22 insertions(+), 263 deletions(-) delete mode 100644 gnovm/pkg/doctest/pkg_loader.go delete mode 100644 gnovm/pkg/doctest/pkg_loader_test.go diff --git a/gnovm/pkg/doctest/exec.go b/gnovm/pkg/doctest/exec.go index 57a62a920a4..111c70b2d11 100644 --- a/gnovm/pkg/doctest/exec.go +++ b/gnovm/pkg/doctest/exec.go @@ -9,19 +9,17 @@ import ( "sync" "github.com/gnolang/gno/gno.land/pkg/sdk/vm" - "github.com/gnolang/gno/gnovm/pkg/gnolang" bft "github.com/gnolang/gno/tm2/pkg/bft/types" "github.com/gnolang/gno/tm2/pkg/crypto" "github.com/gnolang/gno/tm2/pkg/db/memdb" "github.com/gnolang/gno/tm2/pkg/log" "github.com/gnolang/gno/tm2/pkg/sdk" - "github.com/gnolang/gno/tm2/pkg/sdk/auth" + authm "github.com/gnolang/gno/tm2/pkg/sdk/auth" bankm "github.com/gnolang/gno/tm2/pkg/sdk/bank" "github.com/gnolang/gno/tm2/pkg/std" "github.com/gnolang/gno/tm2/pkg/store" "github.com/gnolang/gno/tm2/pkg/store/dbadapter" "github.com/gnolang/gno/tm2/pkg/store/iavl" - "github.com/gnolang/gno/tm2/pkg/store/types" ) // Option constants @@ -32,7 +30,10 @@ const ( ) func GetStdlibsDir() string { - _, filename, _, _ := runtime.Caller(0) + _, filename, _, ok := runtime.Caller(0) + if !ok { + panic("cannot get current file path") + } return filepath.Join(filepath.Dir(filename), "..", "..", "stdlibs") } @@ -80,33 +81,31 @@ func ExecuteCodeBlock(c codeBlock, stdlibDir string) (string, error) { baseKey := store.NewStoreKey("baseKey") iavlKey := store.NewStoreKey("iavlKey") - ms, ctx := setupMultiStore(baseKey, iavlKey) - - acck := auth.NewAccountKeeper(iavlKey, std.ProtoBaseAccount) - bank := bankm.NewBankKeeper(acck) - - vmk := vm.NewVMKeeper(baseKey, iavlKey, acck, bank, stdlibDir, 100_000_000) - vmk.Initialize(ms.MultiCacheWrap()) + db := memdb.NewMemDB() - addr := crypto.AddressFromPreimage([]byte("addr1")) - acc := acck.NewAccountWithAddress(ctx, addr) - acck.SetAccount(ctx, acc) + ms := store.NewCommitMultiStore(db) + ms.MountStoreWithDB(baseKey, dbadapter.StoreConstructor, db) + ms.MountStoreWithDB(iavlKey, iavl.StoreConstructor, db) + ms.LoadLatestVersion() - memPkg := &std.MemPackage{ - Name: "main", - Path: "main", - Files: []*std.MemFile{{Name: fmt.Sprintf("%d.%s", c.index, c.lang), Body: src}}, - } + ctx := sdk.NewContext(sdk.RunTxModeDeliver, ms, &bft.Header{ChainID: "test-chain-id"}, log.NewNoopLogger()) + acck := authm.NewAccountKeeper(iavlKey, std.ProtoBaseAccount) + bank := bankm.NewBankKeeper(acck) + stdlibsDir := GetStdlibsDir() + vmk := vm.NewVMKeeper(baseKey, iavlKey, acck, bank, stdlibsDir, 100_000_000) - getter := newDynPackageLoader(stdlibDir) - if err := gnolang.TypeCheckMemPackage(memPkg, getter); err != nil { - return "", fmt.Errorf("type checking failed: %w", err) - } + mcw := ms.MultiCacheWrap() + vmk.Initialize(log.NewNoopLogger(), mcw, true) + mcw.MultiWrite() files := []*std.MemFile{ {Name: fmt.Sprintf("%d.%s", c.index, c.lang), Body: src}, } + addr := crypto.AddressFromPreimage([]byte("addr1")) + acc := acck.NewAccountWithAddress(ctx, addr) + acck.SetAccount(ctx, acc) + coins := std.MustParseCoins("") msg2 := vm.NewMsgRun(addr, coins, files) @@ -121,15 +120,3 @@ func ExecuteCodeBlock(c codeBlock, stdlibDir string) (string, error) { return res, nil } - -func setupMultiStore(baseKey, iavlKey types.StoreKey) (types.CommitMultiStore, sdk.Context) { - db := memdb.NewMemDB() - - ms := store.NewCommitMultiStore(db) - ms.MountStoreWithDB(baseKey, dbadapter.StoreConstructor, db) - ms.MountStoreWithDB(iavlKey, iavl.StoreConstructor, db) - ms.LoadLatestVersion() - - ctx := sdk.NewContext(sdk.RunTxModeDeliver, ms, &bft.Header{ChainID: "chain-id"}, log.NewNoopLogger()) - return ms, ctx -} diff --git a/gnovm/pkg/doctest/pkg_loader.go b/gnovm/pkg/doctest/pkg_loader.go deleted file mode 100644 index 42798a64590..00000000000 --- a/gnovm/pkg/doctest/pkg_loader.go +++ /dev/null @@ -1,84 +0,0 @@ -package doctest - -import ( - "os" - "path/filepath" - "strings" - - "github.com/gnolang/gno/tm2/pkg/std" -) - -type dynPackageLoader struct { - stdlibDir string - cache map[string]*std.MemPackage -} - -func newDynPackageLoader(stdlibDir string) *dynPackageLoader { - return &dynPackageLoader{ - stdlibDir: stdlibDir, - cache: make(map[string]*std.MemPackage), - } -} - -func (d *dynPackageLoader) GetMemPackage(path string) *std.MemPackage { - if pkg, ok := d.cache[path]; ok { - return pkg - } - - pkg, err := d.loadPackage(path) - if err != nil { - return nil - } - - d.cache[path] = pkg - return pkg -} - -func (d *dynPackageLoader) loadPackage(path string) (*std.MemPackage, error) { - pkgDir := filepath.Join(d.stdlibDir, path) - files, err := os.ReadDir(pkgDir) - if err != nil { - return nil, err - } - - memFiles := []*std.MemFile{} - for _, file := range files { - if file.IsDir() || !strings.HasSuffix(file.Name(), ".gno") { - continue - } - - content, err := os.ReadFile(filepath.Join(pkgDir, file.Name())) - if err != nil { - return nil, err - } - - memFiles = append(memFiles, &std.MemFile{ - Name: file.Name(), - Body: string(content), - }) - } - - pkgName := "" - if len(memFiles) > 0 { - pkgName = extractPackageName(memFiles[0].Body) - } - - return &std.MemPackage{ - Name: pkgName, - Path: path, - Files: memFiles, - }, nil -} - -func extractPackageName(content string) string { - lines := strings.Split(content, "\n") - for _, line := range lines { - if strings.HasPrefix(strings.TrimSpace(line), "package ") { - parts := strings.Fields(line) - if len(parts) >= 2 { - return parts[1] - } - } - } - return "" -} diff --git a/gnovm/pkg/doctest/pkg_loader_test.go b/gnovm/pkg/doctest/pkg_loader_test.go deleted file mode 100644 index b2f6c568b61..00000000000 --- a/gnovm/pkg/doctest/pkg_loader_test.go +++ /dev/null @@ -1,144 +0,0 @@ -package doctest - -import ( - "os" - "path/filepath" - "testing" -) - -func TestDynPackageGetter(t *testing.T) { - tempDir, err := os.MkdirTemp("", "test-stdlib") - if err != nil { - t.Fatalf("failed to create temp dir: %v", err) - } - defer os.RemoveAll(tempDir) - - testPkgs := map[string]map[string]string{ - "std": { - "std.gno": ` -package std -type Address string -`, - }, - "math": { - "math.gno": ` -package math -func Add(a, b int) int { return a + b }`, - "consts.gno": ` -package math -const Pi = 3.14159`, - }, - } - - for pkgName, files := range testPkgs { - pkgDir := filepath.Join(tempDir, pkgName) - if err := os.Mkdir(pkgDir, 0o755); err != nil { - t.Fatalf("failed to create package directory: %v", err) - } - for fileName, content := range files { - if err := os.WriteFile(filepath.Join(pkgDir, fileName), []byte(content), 0o644); err != nil { - t.Fatalf("failed to write file: %v", err) - } - } - } - - getter := newDynPackageLoader(tempDir) - - tests := []struct { - name string - path string - wantPkg bool - wantName string - wantPath string - wantFiles int - }{ - { - name: "Std package", - path: "std", - wantPkg: true, - wantName: "std", - wantPath: "std", - wantFiles: 1, - }, - { - name: "Math package", - path: "math", - wantPkg: true, - wantName: "math", - wantPath: "math", - wantFiles: 2, - }, - { - name: "Non-existent package", - path: "nonexistent", - wantPkg: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - pkg := getter.GetMemPackage(tt.path) - if tt.wantPkg { - if pkg == nil { - t.Fatalf("expected package %s to exist", tt.path) - } - if pkg.Name != tt.wantName { - t.Errorf("expected package name %s, got %s", tt.wantName, pkg.Name) - } - if pkg.Path != tt.wantPath { - t.Errorf("expected package path %s, got %s", tt.wantPath, pkg.Path) - } - if len(pkg.Files) != tt.wantFiles { - t.Errorf("expected %d files, got %d", tt.wantFiles, len(pkg.Files)) - } - } else { - if pkg != nil { - t.Errorf("expected package %s not to exist", tt.path) - } - } - }) - } -} - -func TestExtractPackageName(t *testing.T) { - tests := []struct { - name string - content string - want string - }{ - { - name: "Simple package", - content: `package simple -func Foo() {}`, - want: "simple", - }, - { - name: "Package with comments", - content: `// This is a comment -package withcomments -import "fmt" -func Bar() {}`, - want: "withcomments", - }, - { - name: "Package name with underscore", - content: `package with_underscore -var x = 10`, - want: "with_underscore", - }, - { - name: "No package declaration", - content: `func Baz() {}`, - want: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := extractPackageName(tt.content) - if got != tt.want { - t.Errorf("expected package name %s, got %s", tt.want, got) - } - }) - } -} From 82e02f02d2660cd9a1f134e85fb6dfd0d7f2b180 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Wed, 10 Jul 2024 11:49:07 +0900 Subject: [PATCH 23/37] expect result --- gnovm/pkg/doctest/exec.go | 26 +++- gnovm/pkg/doctest/exec_test.go | 255 ++++++++++++++++--------------- gnovm/pkg/doctest/parser.go | 78 ++++++++-- gnovm/pkg/doctest/parser_test.go | 119 +++++++++++++++ 4 files changed, 343 insertions(+), 135 deletions(-) diff --git a/gnovm/pkg/doctest/exec.go b/gnovm/pkg/doctest/exec.go index 111c70b2d11..1950cb29205 100644 --- a/gnovm/pkg/doctest/exec.go +++ b/gnovm/pkg/doctest/exec.go @@ -6,6 +6,7 @@ import ( "fmt" "path/filepath" "runtime" + "strings" "sync" "github.com/gnolang/gno/gno.land/pkg/sdk/vm" @@ -70,6 +71,10 @@ func ExecuteCodeBlock(c codeBlock, stdlibDir string) (string, error) { cache.RUnlock() if found { + result, err := compareResults(result, c.expectedOutput, c.expectedError) + if err != nil { + return "", err + } return fmt.Sprintf("%s (cached)", result), nil } @@ -118,5 +123,24 @@ func ExecuteCodeBlock(c codeBlock, stdlibDir string) (string, error) { cache.m[hashKey] = res cache.Unlock() - return res, nil + // return res, nil + return compareResults(res, c.expectedOutput, c.expectedError) } + +func compareResults(actual, expectedOutput, expectedError string) (string, error) { + actual = strings.TrimSpace(actual) + expectedOutput = strings.TrimSpace(expectedOutput) + expectedError = strings.TrimSpace(expectedError) + + if expectedOutput != "" { + if actual != expectedOutput { + return "", fmt.Errorf("expected output:\n%s\n\nbut got:\n%s", expectedOutput, actual) + } + } else if expectedError != "" { + if actual != expectedError { + return "", fmt.Errorf("expected error:\n%s\n\nbut got:\n%s", expectedError, actual) + } + } + + return actual, nil +} \ No newline at end of file diff --git a/gnovm/pkg/doctest/exec_test.go b/gnovm/pkg/doctest/exec_test.go index c48040c5faa..5be1f5877fe 100644 --- a/gnovm/pkg/doctest/exec_test.go +++ b/gnovm/pkg/doctest/exec_test.go @@ -1,133 +1,10 @@ package doctest import ( + "strings" "testing" ) -func TestExecuteCodeBlock(t *testing.T) { - clearCache() - tests := []struct { - name string - codeBlock codeBlock - expected string - }{ - { - name: "import go stdlib package", - codeBlock: codeBlock{ - content: ` -package main - -func main() { - println("Hello, World") -}`, - lang: "gno", - }, - expected: "Hello, World\n", - }, - { - name: "import go stdlib package", - codeBlock: codeBlock{ - content: ` -package main - -import "std" - -func main() { - addr := std.GetOrigCaller() - println(addr) -}`, - lang: "gno", - }, - expected: "g14ch5q26mhx3jk5cxl88t278nper264ces4m8nt\n", - }, - { - name: "import go stdlib package", - codeBlock: codeBlock{ - content: ` -package main - -import "strings" - -func main() { - println(strings.ToUpper("Hello, World")) -}`, - lang: "gno", - }, - expected: "HELLO, WORLD\n", - }, - { - name: "print multiple values", - codeBlock: codeBlock{ - content: ` -package main - -func main() { - count := 3 - for i := 0; i < count; i++ { - println("Hello") - } -}`, - lang: "gno", - }, - expected: "Hello\nHello\nHello\n", - }, - { - name: "import subpackage without package declaration", - codeBlock: codeBlock{ - content: ` -func main() { - println(math.Pi) - println(strings.ToUpper("Hello, World")) -}`, - lang: "gno", - }, - expected: "3.141592653589793\nHELLO, WORLD\n", - }, - { - name: "test", - codeBlock: codeBlock{ - content: "package main\n\nfunc main() {\nprintln(\"Hello, World!\")\n}", - lang: "gno", - }, - expected: "Hello, World!\n", - }, - { - name: "missing package declaration", - codeBlock: codeBlock{ - content: "func main() {\nprintln(\"Hello, World!\")\n}", - lang: "gno", - }, - expected: "Hello, World!\n", - }, - { - name: "missing package and import declaration", - codeBlock: codeBlock{ - content: ` -func main() { - s := strings.ToUpper("Hello, World") - println(s) -}`, - lang: "gno", - }, - expected: "HELLO, WORLD\n", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - stdlidDir := GetStdlibsDir() - res, err := ExecuteCodeBlock(tt.codeBlock, stdlidDir) - if err != nil { - t.Errorf("%s returned an error: %v", tt.name, err) - } - - if res != tt.expected { - t.Errorf("%s = %v, want %v", tt.name, res, tt.expected) - } - }) - } -} - func clearCache() { cache.Lock() cache.m = make(map[string]string) @@ -225,3 +102,133 @@ func main() { t.Errorf("hash key for code block 1 and 3 are the same: %v", hashKey1) } } + +func TestExecuteCodeBlock(t *testing.T) { + tests := []struct { + name string + codeBlock codeBlock + expectedResult string + expectError bool + }{ + { + name: "Simple print without expected output", + codeBlock: codeBlock{ + content: ` +package main + +func main() { + println("Hello, World!") +}`, + lang: "gno", + }, + expectedResult: "Hello, World!\n", + }, + { + name: "Print with expected output", + codeBlock: codeBlock{ + content: ` +package main + +func main() { + println("Hello, Gno!") +} +// Output: +// Hello, Gno!`, + lang: "gno", + expectedOutput: "Hello, Gno!", + }, + expectedResult: "Hello, Gno!\n", + }, + { + name: "Print with incorrect expected output", + codeBlock: codeBlock{ + content: ` +package main + +func main() { + println("Hello, Gno!") +} +// Output: +// Hello, World!`, + lang: "gno", + expectedOutput: "Hello, World!", + }, + expectError: true, + }, + { + name: "Code with expected error", + codeBlock: codeBlock{ + content: ` +package main + +func main() { + panic("oops") +} +// Error: +// panic: oops`, + lang: "gno", + expectedError: "panic: oops", + }, + expectError: true, + }, + { + name: "Code with unexpected error", + codeBlock: codeBlock{ + content: ` +package main + +func main() { + panic("unexpected error") +}`, + lang: "gno", + }, + expectError: true, + }, + { + name: "Multiple print statements", + codeBlock: codeBlock{ + content: ` +package main + +func main() { + println("Line 1") + println("Line 2") +} +// Output: +// Line 1 +// Line 2`, + lang: "gno", + expectedOutput: "Line 1\nLine 2", + }, + expectedResult: "Line 1\nLine 2\n", + }, + { + name: "Unsupported language", + codeBlock: codeBlock{ + content: `print("Hello")`, + lang: "python", + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ExecuteCodeBlock(tt.codeBlock, GetStdlibsDir()) + + if tt.expectError { + if err == nil { + t.Errorf("Expected an error, but got none") + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + } + + if strings.TrimSpace(result) != strings.TrimSpace(tt.expectedResult) { + t.Errorf("Expected result %q, but got %q", tt.expectedResult, result) + } + }) + } +} diff --git a/gnovm/pkg/doctest/parser.go b/gnovm/pkg/doctest/parser.go index b27f187192f..f3b5821f514 100644 --- a/gnovm/pkg/doctest/parser.go +++ b/gnovm/pkg/doctest/parser.go @@ -2,6 +2,8 @@ package doctest import ( "bytes" + "regexp" + "strings" "github.com/yuin/goldmark" "github.com/yuin/goldmark/ast" @@ -10,11 +12,13 @@ import ( // codeBlock represents a block of code extracted from the input text. type codeBlock struct { - content string // The content of the code block. - start int // The start byte position of the code block in the input text. - end int // The end byte position of the code block in the input text. - lang string // The language type of the code block. - index int // The index of the code block in the sequence of extracted blocks. + content string // The content of the code block. + start int // The start byte position of the code block in the input text. + end int // The end byte position of the code block in the input text. + lang string // The language type of the code block. + index int // The index of the code block in the sequence of extracted blocks. + expectedOutput string // The expected output of the code block. + expectedError string // The expected error of the code block. } // GetCodeBlocks extracts all code blocks from the provided markdown text. @@ -53,11 +57,65 @@ func createCodeBlock(node *ast.FencedCodeBlock, body string, index int) codeBloc start := node.Lines().At(0).Start end := node.Lines().At(node.Lines().Len() - 1).Stop + expectedOutput, expectedError, err := parseExpectedResults(content) + if err != nil { + panic(err) + } + return codeBlock{ - content: content, - start: start, - end: end, - lang: language, - index: index, + content: content, + start: start, + end: end, + lang: language, + index: index, + expectedOutput: expectedOutput, + expectedError: expectedError, } } + +func parseExpectedResults(content string) (string, string, error) { + outputRegex := regexp.MustCompile(`(?m)^// Output:$([\s\S]*?)(?:^(?://\s*$|// Error:|$))`) + errorRegex := regexp.MustCompile(`(?m)^// Error:$([\s\S]*?)(?:^(?://\s*$|// Output:|$))`) + + var outputs, errors []string + + cleanSection := func(section string) string { + lines := strings.Split(section, "\n") + var cleanedLines []string + for _, line := range lines { + trimmedLine := strings.TrimPrefix(line, "//") + if len(trimmedLine) > 0 && trimmedLine[0] == ' ' { + trimmedLine = trimmedLine[1:] + } + if trimmedLine != "" { + cleanedLines = append(cleanedLines, trimmedLine) + } + } + return strings.Join(cleanedLines, "\n") + } + + outputMatches := outputRegex.FindAllStringSubmatch(content, -1) + for _, match := range outputMatches { + if len(match) > 1 { + cleaned := cleanSection(match[1]) + if cleaned != "" { + outputs = append(outputs, cleaned) + } + } + } + + errorMatches := errorRegex.FindAllStringSubmatch(content, -1) + for _, match := range errorMatches { + if len(match) > 1 { + cleaned := cleanSection(match[1]) + if cleaned != "" { + errors = append(errors, cleaned) + } + } + } + + expectedOutput := strings.Join(outputs, "\n") + expectedError := strings.Join(errors, "\n") + + return expectedOutput, expectedError, nil +} \ No newline at end of file diff --git a/gnovm/pkg/doctest/parser_test.go b/gnovm/pkg/doctest/parser_test.go index fefad773f92..6e01ee3e2be 100644 --- a/gnovm/pkg/doctest/parser_test.go +++ b/gnovm/pkg/doctest/parser_test.go @@ -125,6 +125,125 @@ func TestGetCodeBlocks(t *testing.T) { } } +func TestParseExpectedResults(t *testing.T) { + tests := []struct { + name string + content string + wantOutput string + wantError string + wantParseError bool + }{ + { + name: "Basic output", + content: ` +// Some code +fmt.Println("Hello, World!") +// Output: +// Hello, World! +`, + wantOutput: "Hello, World!", + wantError: "", + }, + { + name: "Basic error", + content: ` +// Some code that causes an error +panic("oops") +// Error: +// panic: oops +`, + wantOutput: "", + wantError: "panic: oops", + }, + { + name: "Output and error", + content: ` +// Some code with both output and error +fmt.Println("Start") +panic("oops") +// Output: +// Start +// Error: +// panic: oops +`, + wantOutput: "Start", + wantError: "panic: oops", + }, + { + name: "Multiple output sections", + content: ` +// First output +fmt.Println("Hello") +// Output: +// Hello +// World +`, + wantOutput: "Hello\nWorld", + wantError: "", + }, + { + name: "Preserve indentation", + content: ` +// Indented output +fmt.Println(" Indented") +// Output: +// Indented +`, + wantOutput: " Indented", + wantError: "", + }, + { + name: "Output with // in content", + content: ` +// Output with // +fmt.Println("// Comment") +// Output: +// // Comment +`, + wantOutput: "// Comment", + wantError: "", + }, + { + name: "Empty content", + content: ` +// Just some comments +// No output or error +`, + wantOutput: "", + wantError: "", + }, + { + name: "simple code", + content: ` +package main + +func main() { + println("Actual output") +} +// Output: +// Actual output +`, + wantOutput: "Actual output", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotOutput, gotError, err := parseExpectedResults(tt.content) + if (err != nil) != tt.wantParseError { + t.Errorf("parseExpectedResults() error = %v, wantParseError %v", err, tt.wantParseError) + return + } + if gotOutput != tt.wantOutput { + t.Errorf("parseExpectedResults() gotOutput = %v, want %v", gotOutput, tt.wantOutput) + } + if gotError != tt.wantError { + t.Errorf("parseExpectedResults() gotError = %v, want %v", gotError, tt.wantError) + } + }) + } +} + // ignore whitespace in the source code func normalize(s string) string { return strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(s, "\n", ""), "\r", ""), "\t", ""), " ", "") From 273abb5c60a1b3afe28e8eecad15653bd4f6c3cc Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Wed, 10 Jul 2024 14:27:39 +0900 Subject: [PATCH 24/37] auto test name generator --- gnovm/pkg/doctest/exec.go | 2 +- gnovm/pkg/doctest/exec_test.go | 8 +- gnovm/pkg/doctest/parser.go | 239 ++++++++++++++++++++++++------- gnovm/pkg/doctest/parser_test.go | 120 ++++++++++++++++ gnovm/pkg/doctest/test.go | 82 +++++++++++ 5 files changed, 394 insertions(+), 57 deletions(-) create mode 100644 gnovm/pkg/doctest/test.go diff --git a/gnovm/pkg/doctest/exec.go b/gnovm/pkg/doctest/exec.go index 1950cb29205..c82bd6eb325 100644 --- a/gnovm/pkg/doctest/exec.go +++ b/gnovm/pkg/doctest/exec.go @@ -143,4 +143,4 @@ func compareResults(actual, expectedOutput, expectedError string) (string, error } return actual, nil -} \ No newline at end of file +} diff --git a/gnovm/pkg/doctest/exec_test.go b/gnovm/pkg/doctest/exec_test.go index 5be1f5877fe..10be70b855b 100644 --- a/gnovm/pkg/doctest/exec_test.go +++ b/gnovm/pkg/doctest/exec_test.go @@ -153,7 +153,7 @@ func main() { lang: "gno", expectedOutput: "Hello, World!", }, - expectError: true, + expectError: true, }, { name: "Code with expected error", @@ -169,7 +169,7 @@ func main() { lang: "gno", expectedError: "panic: oops", }, - expectError: true, + expectError: true, }, { name: "Code with unexpected error", @@ -182,7 +182,7 @@ func main() { }`, lang: "gno", }, - expectError: true, + expectError: true, }, { name: "Multiple print statements", @@ -208,7 +208,7 @@ func main() { content: `print("Hello")`, lang: "python", }, - expectError: true, + expectError: true, }, } diff --git a/gnovm/pkg/doctest/parser.go b/gnovm/pkg/doctest/parser.go index f3b5821f514..188703c155c 100644 --- a/gnovm/pkg/doctest/parser.go +++ b/gnovm/pkg/doctest/parser.go @@ -2,6 +2,10 @@ package doctest import ( "bytes" + "fmt" + gast "go/ast" + "go/parser" + "go/token" "regexp" "strings" @@ -12,16 +16,18 @@ import ( // codeBlock represents a block of code extracted from the input text. type codeBlock struct { - content string // The content of the code block. - start int // The start byte position of the code block in the input text. - end int // The end byte position of the code block in the input text. - lang string // The language type of the code block. - index int // The index of the code block in the sequence of extracted blocks. + content string // The content of the code block. + start int // The start byte position of the code block in the input text. + end int // The end byte position of the code block in the input text. + lang string // The language type of the code block. + index int // The index of the code block in the sequence of extracted blocks. expectedOutput string // The expected output of the code block. expectedError string // The expected error of the code block. + name string // The name of the code block. } -// GetCodeBlocks extracts all code blocks from the provided markdown text. +// GetCodeBlocks parses the provided markdown text to extract all embedded code blocks. +// It returns a slice of codeBlock structs, each representing a distinct block of code found in the markdown. func GetCodeBlocks(body string) []codeBlock { md := goldmark.New() reader := text.NewReader([]byte(body)) @@ -32,6 +38,7 @@ func GetCodeBlocks(body string) []codeBlock { if entering { if cb, ok := n.(*ast.FencedCodeBlock); ok { codeBlock := createCodeBlock(cb, body, len(codeBlocks)) + codeBlock.name = generateCodeBlockName(codeBlock.content) codeBlocks = append(codeBlocks, codeBlock) } } @@ -41,7 +48,7 @@ func GetCodeBlocks(body string) []codeBlock { return codeBlocks } -// createCodeBlock creates a CodeBlock from a goldmark FencedCodeBlock node. +// createCodeBlock creates a CodeBlock from a code block node. func createCodeBlock(node *ast.FencedCodeBlock, body string, index int) codeBlock { var buf bytes.Buffer for i := 0; i < node.Lines().Len(); i++ { @@ -73,49 +80,177 @@ func createCodeBlock(node *ast.FencedCodeBlock, body string, index int) codeBloc } } +// parseExpectedResults scans the code block content for expecting outputs and errors, +// which are typically indicated by special comments in the code. func parseExpectedResults(content string) (string, string, error) { - outputRegex := regexp.MustCompile(`(?m)^// Output:$([\s\S]*?)(?:^(?://\s*$|// Error:|$))`) - errorRegex := regexp.MustCompile(`(?m)^// Error:$([\s\S]*?)(?:^(?://\s*$|// Output:|$))`) - - var outputs, errors []string - - cleanSection := func(section string) string { - lines := strings.Split(section, "\n") - var cleanedLines []string - for _, line := range lines { - trimmedLine := strings.TrimPrefix(line, "//") - if len(trimmedLine) > 0 && trimmedLine[0] == ' ' { - trimmedLine = trimmedLine[1:] - } - if trimmedLine != "" { - cleanedLines = append(cleanedLines, trimmedLine) - } - } - return strings.Join(cleanedLines, "\n") - } - - outputMatches := outputRegex.FindAllStringSubmatch(content, -1) - for _, match := range outputMatches { - if len(match) > 1 { - cleaned := cleanSection(match[1]) - if cleaned != "" { - outputs = append(outputs, cleaned) - } - } - } - - errorMatches := errorRegex.FindAllStringSubmatch(content, -1) - for _, match := range errorMatches { - if len(match) > 1 { - cleaned := cleanSection(match[1]) - if cleaned != "" { - errors = append(errors, cleaned) - } - } - } - - expectedOutput := strings.Join(outputs, "\n") - expectedError := strings.Join(errors, "\n") - - return expectedOutput, expectedError, nil -} \ No newline at end of file + outputRegex := regexp.MustCompile(`(?m)^// Output:$([\s\S]*?)(?:^(?://\s*$|// Error:|$))`) + errorRegex := regexp.MustCompile(`(?m)^// Error:$([\s\S]*?)(?:^(?://\s*$|// Output:|$))`) + + var outputs, errors []string + + cleanSection := func(section string) string { + lines := strings.Split(section, "\n") + var cleanedLines []string + for _, line := range lines { + trimmedLine := strings.TrimPrefix(line, "//") + if len(trimmedLine) > 0 && trimmedLine[0] == ' ' { + trimmedLine = trimmedLine[1:] + } + if trimmedLine != "" { + cleanedLines = append(cleanedLines, trimmedLine) + } + } + return strings.Join(cleanedLines, "\n") + } + + outputMatches := outputRegex.FindAllStringSubmatch(content, -1) + for _, match := range outputMatches { + if len(match) > 1 { + cleaned := cleanSection(match[1]) + if cleaned != "" { + outputs = append(outputs, cleaned) + } + } + } + + errorMatches := errorRegex.FindAllStringSubmatch(content, -1) + for _, match := range errorMatches { + if len(match) > 1 { + cleaned := cleanSection(match[1]) + if cleaned != "" { + errors = append(errors, cleaned) + } + } + } + + expectedOutput := strings.Join(outputs, "\n") + expectedError := strings.Join(errors, "\n") + + return expectedOutput, expectedError, nil +} + +// generateCodeBlockName derives a name for the code block based either on special annotations within the code +// or by analyzing the code structure, such as function name or variable declaration. +func generateCodeBlockName(content string) string { + lines := strings.Split(content, "\n") + for _, line := range lines { + if strings.HasPrefix(strings.TrimSpace(line), "// @test:") { + return strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "// @test:")) + } + } + + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "", content, parser.ParseComments) + if err != nil { + return generateFallbackName(content) + } + + var mainFunc *gast.FuncDecl + for _, decl := range f.Decls { + if fn, ok := decl.(*gast.FuncDecl); ok { + if fn.Name.Name == "main" { + mainFunc = fn // save the main and keep looking for a better name + } else { + return generateFunctionName(fn) + } + } + } + + // analyze main function if it only exists + if mainFunc != nil { + return analyzeMainFunction(mainFunc) + } + + // find the first top-level declaration + for _, decl := range f.Decls { + switch d := decl.(type) { + case *gast.GenDecl: + if len(d.Specs) > 0 { + switch s := d.Specs[0].(type) { + case *gast.ValueSpec: + if len(s.Names) > 0 { + return s.Names[0].Name + } + case *gast.TypeSpec: + return s.Name.Name + } + } + } + } + + return generateFallbackName(content) +} + +// generateFunctionName creates a descriptive name for a function declaration, +// including the function name and its parameters. +func generateFunctionName(fn *gast.FuncDecl) string { + params := make([]string, 0) + if fn.Type.Params != nil { + for _, param := range fn.Type.Params.List { + paramType := "" + if ident, ok := param.Type.(*gast.Ident); ok { + paramType = ident.Name + } + for _, name := range param.Names { + params = append(params, fmt.Sprintf("%s %s", name.Name, paramType)) + } + } + } + return fmt.Sprintf("%s(%s)", fn.Name.Name, strings.Join(params, ", ")) +} + +// analyzeMainFunction examines the main function declaration to extract a meaningful name, +// typically based on the first significant call expression within the function body. +func analyzeMainFunction(fn *gast.FuncDecl) string { + if fn.Body == nil { + return "main()" + } + for _, stmt := range fn.Body.List { + if exprStmt, ok := stmt.(*gast.ExprStmt); ok { + if callExpr, ok := exprStmt.X.(*gast.CallExpr); ok { + return generateCallExprName(callExpr) + } + } + } + return "main()" +} + +// generateCallExprName constructs a name for call expression by extracting the function name +// and formatting the arguments into a readable string. +func generateCallExprName(callExpr *gast.CallExpr) string { + funcName := "" + if ident, ok := callExpr.Fun.(*gast.Ident); ok { + funcName = ident.Name + } else if selectorExpr, ok := callExpr.Fun.(*gast.SelectorExpr); ok { + if ident, ok := selectorExpr.X.(*gast.Ident); ok { + funcName = fmt.Sprintf("%s.%s", ident.Name, selectorExpr.Sel.Name) + } + } + + args := make([]string, 0) + for _, arg := range callExpr.Args { + if basicLit, ok := arg.(*gast.BasicLit); ok { + args = append(args, basicLit.Value) + } else if ident, ok := arg.(*gast.Ident); ok { + args = append(args, ident.Name) + } + } + + return fmt.Sprintf("%s(%s)", funcName, strings.Join(args, ", ")) +} + +// generateFallbackName generates a default name for a code block when no other name could be determined. +// It uses the first significant line of the code that is not a comment or package declaration. +func generateFallbackName(content string) string { + lines := strings.Split(content, "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed != "" && !strings.HasPrefix(trimmed, "//") && trimmed != "package main" { + if len(trimmed) > 20 { + return trimmed[:20] + "..." + } + return trimmed + } + } + return "unnamed_block" +} diff --git a/gnovm/pkg/doctest/parser_test.go b/gnovm/pkg/doctest/parser_test.go index 6e01ee3e2be..57b2976d8fc 100644 --- a/gnovm/pkg/doctest/parser_test.go +++ b/gnovm/pkg/doctest/parser_test.go @@ -244,6 +244,126 @@ func main() { } } +func TestGenerateCodeBlockName(t *testing.T) { + tests := []struct { + name string + content string + expected string + }{ + { + name: "Function name", + content: ` +package main + +func TestFunction() { + println("Hello") +}`, + expected: "TestFunction()", + }, + { + name: "Main function only", + content: ` +package main + +func main() { + println("Hello") +}`, + expected: "println(\"Hello\")", + }, + { + name: "No function", + content: ` +package main + +var x = 5 +`, + expected: "x", + }, + { + name: "Multiple functions", + content: ` +package main + +func main() { + println("Hello") +} + +func AnotherFunction() { + println("World") +}`, + expected: "AnotherFunction()", + }, + { + name: "Empty content", + content: "", + expected: "unnamed_block", + }, + { + name: "Only comments", + content: ` +// This is a comment +// Another comment +`, + expected: "unnamed_block", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := generateCodeBlockName(tt.content) + if result != tt.expected { + t.Errorf("generateCodeBlockName() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestGetCodeBlocks_Name(t *testing.T) { + markdown := ` +Some text here + +` + "```go" + ` +// @test: CustomNamedTest +func main() { + println("Custom named test") +} +` + "```" + ` + +Another paragraph + +` + "```go" + ` +func TestAutoNamed() { + println("Auto named test") +} +` + "```" + ` + +` + "```go" + ` +var x = 5 +` + "```" + ` +` + + codeBlocks := GetCodeBlocks(markdown) + + if len(codeBlocks) != 3 { + t.Fatalf("Expected 3 code blocks, got %d", len(codeBlocks)) + } + + // Test custom named block + if codeBlocks[0].name != "CustomNamedTest" { + t.Errorf("Expected first block name to be 'CustomNamedTest', got '%s'", codeBlocks[0].name) + } + + // Test auto named block with function + if codeBlocks[1].name != "func TestAutoNamed()..." { + t.Errorf("Expected second block name to be 'func TestAutoNamed()...', got '%s'", codeBlocks[1].name) + } + + // Test auto named block without function + if codeBlocks[2].name != "var x = 5" { + t.Errorf("Expected third block name to be 'var x = 5', got '%s'", codeBlocks[2].name) + } +} + // ignore whitespace in the source code func normalize(s string) string { return strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(s, "\n", ""), "\r", ""), "\t", ""), " ", "") diff --git a/gnovm/pkg/doctest/test.go b/gnovm/pkg/doctest/test.go new file mode 100644 index 00000000000..2320dab83e3 --- /dev/null +++ b/gnovm/pkg/doctest/test.go @@ -0,0 +1,82 @@ +package doctest + +import ( + "fmt" + "time" +) + +type TestResult struct { + Name string + Passed bool + Error error + Duration time.Duration +} + +type TestSummary struct { + Total int + Passed int + Failed int + Skipped int + Time time.Duration +} + +func RunTests(codeBlocks []codeBlock) TestSummary { + summary := TestSummary{} + startTime := time.Now() + + for _, cb := range codeBlocks { + result := runTest(cb) + printTestResult(result) + updateSummary(&summary, result) + } + + summary.Time = time.Since(startTime) + printSummary(summary) + return summary +} + +func runTest(cd codeBlock) TestResult { + start := time.Now() + _, err := ExecuteCodeBlock(cd, GetStdlibsDir()) + duration := time.Since(start) + + return TestResult{ + // TODO add name field + Passed: err == nil, + Error: err, + Duration: duration, + } +} + +func printTestResult(result TestResult) { + status := "PASS" + if !result.Passed { + status = "FAIL" + } + fmt.Printf("--- %s: %s (%.2fs)\n", status, result.Name, result.Duration.Seconds()) + if !result.Passed { + fmt.Printf(" %v\n", result.Error) + } +} + +func updateSummary(summary *TestSummary, result TestResult) { + summary.Total++ + if result.Passed { + summary.Passed++ + } else { + summary.Failed++ + } +} + +func printSummary(summary TestSummary) { + fmt.Printf("\nTest Summary:\n") + fmt.Printf("Total: %d, Passed: %d, Failed: %d, Skipped: %d\n", + summary.Total, summary.Passed, summary.Failed, summary.Skipped) + fmt.Printf("Time: %.2fs\n", summary.Time.Seconds()) + + if summary.Failed > 0 { + fmt.Printf("FAIL\n") + } else { + fmt.Printf("PASS\n") + } +} From 1ee5486ee423215044804461005487e08a1a56c4 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Thu, 11 Jul 2024 18:00:02 +0900 Subject: [PATCH 25/37] use pattern when run code block instead index --- gnovm/cmd/gno/doctest.go | 36 +++--- gnovm/cmd/gno/doctest_test.go | 200 +++++++++++++++++----------------- gnovm/pkg/doctest/exec.go | 35 +++++- gnovm/pkg/doctest/test.go | 82 -------------- 4 files changed, 153 insertions(+), 200 deletions(-) delete mode 100644 gnovm/pkg/doctest/test.go diff --git a/gnovm/cmd/gno/doctest.go b/gnovm/cmd/gno/doctest.go index 16ca18f4a05..9d763c0b34a 100644 --- a/gnovm/cmd/gno/doctest.go +++ b/gnovm/cmd/gno/doctest.go @@ -5,6 +5,7 @@ import ( "flag" "fmt" "os" + "strings" dt "github.com/gnolang/gno/gnovm/pkg/doctest" "github.com/gnolang/gno/tm2/pkg/commands" @@ -12,7 +13,8 @@ import ( type doctestCfg struct { markdownPath string - codeIndex int + // codeIndex int + runPattern string } func newDoctestCmd(io commands.IO) *commands.Command { @@ -21,7 +23,7 @@ func newDoctestCmd(io commands.IO) *commands.Command { return commands.NewCommand( commands.Metadata{ Name: "doctest", - ShortUsage: "doctest -path -index ", + ShortUsage: "doctest -path [-run ]", ShortHelp: "executes a specific code block from a markdown file", }, cfg, @@ -38,12 +40,11 @@ func (c *doctestCfg) RegisterFlags(fs *flag.FlagSet) { "", "path to the markdown file", ) - - fs.IntVar( - &c.codeIndex, - "index", - -1, - "index of the code block to execute", + fs.StringVar( + &c.runPattern, + "run", + "", + "pattern to match code block names", ) } @@ -52,28 +53,23 @@ func execDoctest(cfg *doctestCfg, _ []string, io commands.IO) error { return fmt.Errorf("markdown file path is required") } - if cfg.codeIndex < 0 { - return fmt.Errorf("code block index must be non-negative") - } - content, err := fetchMarkdown(cfg.markdownPath) if err != nil { return fmt.Errorf("failed to read markdown file: %w", err) } - codeBlocks := dt.GetCodeBlocks(content) - if cfg.codeIndex >= len(codeBlocks) { - return fmt.Errorf("invalid code block index: %d", cfg.codeIndex) - } - - selectedCodeBlock := codeBlocks[cfg.codeIndex] - result, err := dt.ExecuteCodeBlock(selectedCodeBlock, dt.GetStdlibsDir()) + results, err := dt.ExecuteMatchingCodeBlock(content, cfg.runPattern) if err != nil { return fmt.Errorf("failed to execute code block: %w", err) } + if len(results) == 0 { + io.Println("No code blocks matched the pattern") + return nil + } + io.Println("Execution Result:") - io.Println(result) + io.Println(strings.Join(results, "\n\n")) return nil } diff --git a/gnovm/cmd/gno/doctest_test.go b/gnovm/cmd/gno/doctest_test.go index f0c89aeee16..5efb8a60385 100644 --- a/gnovm/cmd/gno/doctest_test.go +++ b/gnovm/cmd/gno/doctest_test.go @@ -1,99 +1,105 @@ package main -import ( - "os" - "testing" -) - -func TestDoctest(t *testing.T) { - tempDir, err := os.MkdirTemp("", "doctest-test") - if err != nil { - t.Fatalf("failed to create temp directory: %v", err) - } - defer os.RemoveAll(tempDir) - - markdownContent := `# Go Code Examples - -This document contains two simple examples written in Go. - -## Example 1: Fibonacci Sequence - -The first example prints the first 10 numbers of the Fibonacci sequence. - -` + "```go" + ` -package main - -func main() { - a, b := 0, 1 - for i := 0; i < 10; i++ { - println(a) - a, b = b, a+b - } -} -` + "```" + ` - -## Example 2: String Reversal - -The second example reverses a given string and prints it. - -` + "```go" + ` -package main - -func main() { - str := "Hello, Go!" - runes := []rune(str) - for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 { - runes[i], runes[j] = runes[j], runes[i] - } - println(string(runes)) -} -` + "```" + ` - -These two examples demonstrate basic Go functionality without using concurrency, generics, or reflect.` + "## std Package" + ` -` + "```go" + ` -package main - -import ( - "std" -) - -func main() { - addr := std.GetOrigCaller() - println(addr) -} -` - - mdFile, err := os.CreateTemp(tempDir, "sample-*.md") - if err != nil { - t.Fatalf("failed to create temp file: %v", err) - } - defer mdFile.Close() - - _, err = mdFile.WriteString(markdownContent) - if err != nil { - t.Fatalf("failed to write to temp file: %v", err) - } - - mdFilePath := mdFile.Name() - - tc := []testMainCase{ - { - args: []string{"doctest -h"}, - errShouldBe: "flag: help requested", - }, - { - args: []string{"doctest", "-path", mdFilePath, "-index", "0"}, - stdoutShouldContain: "0\n1\n1\n2\n3\n5\n8\n13\n21\n34\n\n", - }, - { - args: []string{"doctest", "-path", mdFilePath, "-index", "1"}, - stdoutShouldContain: "!oG ,olleH\n", - }, - { - args: []string{"doctest", "-path", mdFilePath, "-index", "2"}, - stdoutShouldContain: "g14ch5q26mhx3jk5cxl88t278nper264ces4m8nt\n", - }, - } - - testMainCaseRun(t, tc) -} +// import ( +// "os" +// "testing" +// ) + +// func TestDoctest(t *testing.T) { +// tempDir, err := os.MkdirTemp("", "doctest-test") +// if err != nil { +// t.Fatalf("failed to create temp directory: %v", err) +// } +// defer os.RemoveAll(tempDir) + +// markdownContent := `# Go Code Examples + +// This document contains two simple examples written in Go. + +// ## Example 1: Fibonacci Sequence + +// The first example prints the first 10 numbers of the Fibonacci sequence. + +// ` + "```go" + ` +// // @test: Fibonacci +// package main + +// func main() { +// a, b := 0, 1 +// for i := 0; i < 10; i++ { +// println(a) +// a, b = b, a+b +// } +// } +// ` + "```" + ` + +// ## Example 2: String Reversal + +// The second example reverses a given string and prints it. + +// ` + "```go" + ` +// // @test: StringReversal +// package main + +// func main() { +// str := "Hello, Go!" +// runes := []rune(str) +// for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 { +// runes[i], runes[j] = runes[j], runes[i] +// } +// println(string(runes)) +// } +// ` + "```" + ` + +// These two examples demonstrate basic Go functionality without using concurrency, generics, or reflect. + +// ` + "## std Package" + ` +// ` + "```go" + ` +// // @test: StdPackage +// package main + +// import ( +// "std" +// ) + +// func main() { +// addr := std.GetOrigCaller() +// println(addr) +// } +// ` + "```" + ` +// ` + +// mdFile, err := os.CreateTemp(tempDir, "sample-*.md") +// if err != nil { +// t.Fatalf("failed to create temp file: %v", err) +// } +// defer mdFile.Close() + +// _, err = mdFile.WriteString(markdownContent) +// if err != nil { +// t.Fatalf("failed to write to temp file: %v", err) +// } + +// mdFilePath := mdFile.Name() + +// tc := []testMainCase{ +// { +// args: []string{"doctest", "-h"}, +// errShouldBe: "flag: help requested", +// }, +// { +// args: []string{"doctest", "-path", mdFilePath, "-run", "Fibonacci"}, +// stdoutShouldContain: "--- Fibonacci ---\n0\n1\n1\n2\n3\n5\n8\n13\n21\n34\n", +// }, +// { +// args: []string{"doctest", "-path", mdFilePath, "-run", "StringReversal"}, +// stdoutShouldContain: "--- StringReversal ---\n!oG ,olleH\n", +// }, +// { +// args: []string{"doctest", "-path", mdFilePath, "-run", "StdPackage"}, +// stdoutShouldContain: "--- StdPackage ---\ng14ch5q26mhx3jk5cxl88t278nper264ces4m8nt\n", +// }, +// } + +// testMainCaseRun(t, tc) +// } diff --git a/gnovm/pkg/doctest/exec.go b/gnovm/pkg/doctest/exec.go index c82bd6eb325..9bb1c39e94a 100644 --- a/gnovm/pkg/doctest/exec.go +++ b/gnovm/pkg/doctest/exec.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "fmt" "path/filepath" + "regexp" "runtime" "strings" "sync" @@ -123,7 +124,6 @@ func ExecuteCodeBlock(c codeBlock, stdlibDir string) (string, error) { cache.m[hashKey] = res cache.Unlock() - // return res, nil return compareResults(res, c.expectedOutput, c.expectedError) } @@ -144,3 +144,36 @@ func compareResults(actual, expectedOutput, expectedError string) (string, error return actual, nil } + +func ExecuteMatchingCodeBlock(content string, pattern string) ([]string, error) { + codeBlocks := GetCodeBlocks(content) + var results []string + + for _, block := range codeBlocks { + if matchPattern(block.name, pattern) { + result, err := ExecuteCodeBlock(block, GetStdlibsDir()) + if err != nil { + return nil, fmt.Errorf("failed to execute code block %s: %w", block.name, err) + } + results = append(results, fmt.Sprintf("\n=== %s ===\n\n%s", block.name, result)) + } + } + + return results, nil +} + +func matchPattern(name, pattern string) bool { + if pattern == "" { + return true + } + + pattern = regexp.QuoteMeta(pattern) + pattern = strings.ReplaceAll(pattern, "\\*", ".*") + + re, err := regexp.Compile(pattern) + if err != nil { + return false + } + + return re.MatchString(name) +} diff --git a/gnovm/pkg/doctest/test.go b/gnovm/pkg/doctest/test.go deleted file mode 100644 index 2320dab83e3..00000000000 --- a/gnovm/pkg/doctest/test.go +++ /dev/null @@ -1,82 +0,0 @@ -package doctest - -import ( - "fmt" - "time" -) - -type TestResult struct { - Name string - Passed bool - Error error - Duration time.Duration -} - -type TestSummary struct { - Total int - Passed int - Failed int - Skipped int - Time time.Duration -} - -func RunTests(codeBlocks []codeBlock) TestSummary { - summary := TestSummary{} - startTime := time.Now() - - for _, cb := range codeBlocks { - result := runTest(cb) - printTestResult(result) - updateSummary(&summary, result) - } - - summary.Time = time.Since(startTime) - printSummary(summary) - return summary -} - -func runTest(cd codeBlock) TestResult { - start := time.Now() - _, err := ExecuteCodeBlock(cd, GetStdlibsDir()) - duration := time.Since(start) - - return TestResult{ - // TODO add name field - Passed: err == nil, - Error: err, - Duration: duration, - } -} - -func printTestResult(result TestResult) { - status := "PASS" - if !result.Passed { - status = "FAIL" - } - fmt.Printf("--- %s: %s (%.2fs)\n", status, result.Name, result.Duration.Seconds()) - if !result.Passed { - fmt.Printf(" %v\n", result.Error) - } -} - -func updateSummary(summary *TestSummary, result TestResult) { - summary.Total++ - if result.Passed { - summary.Passed++ - } else { - summary.Failed++ - } -} - -func printSummary(summary TestSummary) { - fmt.Printf("\nTest Summary:\n") - fmt.Printf("Total: %d, Passed: %d, Failed: %d, Skipped: %d\n", - summary.Total, summary.Passed, summary.Failed, summary.Skipped) - fmt.Printf("Time: %.2fs\n", summary.Time.Seconds()) - - if summary.Failed > 0 { - fmt.Printf("FAIL\n") - } else { - fmt.Printf("PASS\n") - } -} From 4775fd3fc41b2ceea1c071ff2bc2948f0b9ac734 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Fri, 12 Jul 2024 12:46:28 +0900 Subject: [PATCH 26/37] generate better name --- gnovm/cmd/gno/testdata/doctest/1.md | 7 + gnovm/pkg/doctest/parser.go | 254 +++++++++++++++++++--------- gnovm/pkg/doctest/parser_test.go | 170 +++++++++++-------- 3 files changed, 284 insertions(+), 147 deletions(-) diff --git a/gnovm/cmd/gno/testdata/doctest/1.md b/gnovm/cmd/gno/testdata/doctest/1.md index ce2844158b6..a187b5e6d21 100644 --- a/gnovm/cmd/gno/testdata/doctest/1.md +++ b/gnovm/cmd/gno/testdata/doctest/1.md @@ -12,11 +12,15 @@ package main func main() { println("Hello, World!") } + +// Output: +// Hello, World! ``` Doctest also recognizes that a block of code is a gno. The code below outputs the same result as the example above. ```go +// @test: print hello world package main func main() { @@ -79,6 +83,9 @@ func main() { s := strings.ToUpper("Hello, World") println(s) } + +// Output: +// HELLO, WORLD ``` This code runs normally without package declaration or import statements. diff --git a/gnovm/pkg/doctest/parser.go b/gnovm/pkg/doctest/parser.go index 188703c155c..6da909ee893 100644 --- a/gnovm/pkg/doctest/parser.go +++ b/gnovm/pkg/doctest/parser.go @@ -2,15 +2,14 @@ package doctest import ( "bytes" - "fmt" - gast "go/ast" + "go/ast" "go/parser" "go/token" "regexp" "strings" "github.com/yuin/goldmark" - "github.com/yuin/goldmark/ast" + mast "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/text" ) @@ -24,6 +23,7 @@ type codeBlock struct { expectedOutput string // The expected output of the code block. expectedError string // The expected error of the code block. name string // The name of the code block. + options ExecutionOption } // GetCodeBlocks parses the provided markdown text to extract all embedded code blocks. @@ -34,26 +34,27 @@ func GetCodeBlocks(body string) []codeBlock { doc := md.Parser().Parse(reader) var codeBlocks []codeBlock - ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + mast.Walk(doc, func(n mast.Node, entering bool) (mast.WalkStatus, error) { if entering { - if cb, ok := n.(*ast.FencedCodeBlock); ok { + if cb, ok := n.(*mast.FencedCodeBlock); ok { codeBlock := createCodeBlock(cb, body, len(codeBlocks)) codeBlock.name = generateCodeBlockName(codeBlock.content) codeBlocks = append(codeBlocks, codeBlock) } } - return ast.WalkContinue, nil + return mast.WalkContinue, nil }) return codeBlocks } // createCodeBlock creates a CodeBlock from a code block node. -func createCodeBlock(node *ast.FencedCodeBlock, body string, index int) codeBlock { +func createCodeBlock(node *mast.FencedCodeBlock, body string, index int) codeBlock { var buf bytes.Buffer + lines := node.Lines() for i := 0; i < node.Lines().Len(); i++ { - line := node.Lines().At(i) - buf.Write(line.Value([]byte(body))) + line := lines.At(i) + buf.Write([]byte(body[line.Start:line.Stop])) } content := buf.String() @@ -61,8 +62,12 @@ func createCodeBlock(node *ast.FencedCodeBlock, body string, index int) codeBloc if language == "" { language = "plain" } - start := node.Lines().At(0).Start - end := node.Lines().At(node.Lines().Len() - 1).Stop + + firstLine := body[lines.At(0).Start:lines.At(0).Stop] + options := parseExecutionOptions(language, []byte(firstLine)) + + start := lines.At(0).Start + end := lines.At(node.Lines().Len() - 1).Stop expectedOutput, expectedError, err := parseExpectedResults(content) if err != nil { @@ -77,6 +82,7 @@ func createCodeBlock(node *ast.FencedCodeBlock, body string, index int) codeBloc index: index, expectedOutput: expectedOutput, expectedError: expectedError, + options: options, } } @@ -129,8 +135,13 @@ func parseExpectedResults(content string) (string, string, error) { return expectedOutput, expectedError, nil } -// generateCodeBlockName derives a name for the code block based either on special annotations within the code -// or by analyzing the code structure, such as function name or variable declaration. +//////////////////// Auto-Name Generator //////////////////// + +// generateCodeBlockName generates a name for a given code block based on its content. +// It first checks for a custom name specified with `// @test:` comment. +// If not found, it analyzes the code structure to create meaningful name. +// The name is constructed based on the code's prefix (Test, Print or Calc), +// imported packages, main identifier, and expected output. func generateCodeBlockName(content string) string { lines := strings.Split(content, "\n") for _, line := range lines { @@ -145,98 +156,142 @@ func generateCodeBlockName(content string) string { return generateFallbackName(content) } - var mainFunc *gast.FuncDecl + prefix := determinePrefix(f) + imports := extractImports(f) + expectedOutput, _, _ := parseExpectedResults(content) + mainIdentifier := extractMainIdentifier(f) + + name := constructName(prefix, imports, expectedOutput, mainIdentifier) + + return name +} + +// determinePrefix analyzes the AST of a file and determines an appropriate prefix +// for the code block name. +// It returns "Test" for test functions, "Print" for functions containing print statements, +// "Calc" for function containing calculations, or an empty string if no specific prefix is determined. +func determinePrefix(f *ast.File) string { + // determine the prefix by using heuristic for _, decl := range f.Decls { - if fn, ok := decl.(*gast.FuncDecl); ok { - if fn.Name.Name == "main" { - mainFunc = fn // save the main and keep looking for a better name - } else { - return generateFunctionName(fn) + if fn, ok := decl.(*ast.FuncDecl); ok { + if strings.HasPrefix(fn.Name.Name, "Test") { + return "Test" + } + if containsPrintStmt(fn) { + return "Print" + } + if containsCalculation(fn) { + return "Calc" } } } + return "" +} - // analyze main function if it only exists - if mainFunc != nil { - return analyzeMainFunction(mainFunc) - } - - // find the first top-level declaration - for _, decl := range f.Decls { - switch d := decl.(type) { - case *gast.GenDecl: - if len(d.Specs) > 0 { - switch s := d.Specs[0].(type) { - case *gast.ValueSpec: - if len(s.Names) > 0 { - return s.Names[0].Name - } - case *gast.TypeSpec: - return s.Name.Name +// containsPrintStmt checks if the given function declaration contains +// any print or println statements. +func containsPrintStmt(fn *ast.FuncDecl) bool { + var yes bool + ast.Inspect(fn, func(n ast.Node) bool { + if call, ok := n.(*ast.CallExpr); ok { + if ident, ok := call.Fun.(*ast.Ident); ok { + if ident.Name == "println" || ident.Name == "print" { + yes = true + return false } } } - } + return true + }) + return yes +} - return generateFallbackName(content) +// containsCalculation checks if the given function declaration contains +// any binary or unary expressions, which are indicative of calculations. +func containsCalculation(fn *ast.FuncDecl) bool { + var yes bool + ast.Inspect(fn, func(n ast.Node) bool { + switch n.(type) { + case *ast.BinaryExpr, *ast.UnaryExpr: + yes = true + return false + } + return true + }) + return yes } -// generateFunctionName creates a descriptive name for a function declaration, -// including the function name and its parameters. -func generateFunctionName(fn *gast.FuncDecl) string { - params := make([]string, 0) - if fn.Type.Params != nil { - for _, param := range fn.Type.Params.List { - paramType := "" - if ident, ok := param.Type.(*gast.Ident); ok { - paramType = ident.Name - } - for _, name := range param.Names { - params = append(params, fmt.Sprintf("%s %s", name.Name, paramType)) - } +// extractImports extracts the names of imported packages from the AST +// of a Go file. It returns a slice of strings representing the imported +// package names or the last part of the import path if no alias is used. +func extractImports(f *ast.File) []string { + var imports []string + for _, imp := range f.Imports { + if imp.Name != nil { + imports = append(imports, imp.Name.Name) + } else { + path := strings.Trim(imp.Path.Value, `"`) + parts := strings.Split(path, "/") + imports = append(imports, parts[len(parts)-1]) } } - return fmt.Sprintf("%s(%s)", fn.Name.Name, strings.Join(params, ", ")) + return imports } -// analyzeMainFunction examines the main function declaration to extract a meaningful name, -// typically based on the first significant call expression within the function body. -func analyzeMainFunction(fn *gast.FuncDecl) string { - if fn.Body == nil { - return "main()" - } - for _, stmt := range fn.Body.List { - if exprStmt, ok := stmt.(*gast.ExprStmt); ok { - if callExpr, ok := exprStmt.X.(*gast.CallExpr); ok { - return generateCallExprName(callExpr) +// extractMainIdentifier attempts to find the main identifier in the Go file. +// It returns the name of the first function or the first declared variable. +// If no suitable identifier is found, it returns an empty string. +func extractMainIdentifier(f *ast.File) string { + for _, decl := range f.Decls { + switch d := decl.(type) { + case *ast.FuncDecl: + return d.Name.Name + case *ast.GenDecl: + for _, spec := range d.Specs { + if vs, ok := spec.(*ast.ValueSpec); ok { + if len(vs.Names) > 0 { + return vs.Names[0].Name + } + } } } } - return "main()" + return "" } -// generateCallExprName constructs a name for call expression by extracting the function name -// and formatting the arguments into a readable string. -func generateCallExprName(callExpr *gast.CallExpr) string { - funcName := "" - if ident, ok := callExpr.Fun.(*gast.Ident); ok { - funcName = ident.Name - } else if selectorExpr, ok := callExpr.Fun.(*gast.SelectorExpr); ok { - if ident, ok := selectorExpr.X.(*gast.Ident); ok { - funcName = fmt.Sprintf("%s.%s", ident.Name, selectorExpr.Sel.Name) +// constructName builds a name for the code block using the provided components. +// The resulting name is truncated if it exceeds 50 characters. +func constructName( + prefix string, + imports []string, + expectedOutput string, + mainIdentifier string, +) string { + var parts []string + if prefix != "" { + parts = append(parts, prefix) + } + if len(imports) > 0 { + parts = append(parts, strings.Join(imports, "_")) + } + if mainIdentifier != "" { + parts = append(parts, mainIdentifier) + } + if expectedOutput != "" { + // use first line of expected output, limit the length + outputPart := strings.Split(expectedOutput, "\n")[0] + if len(outputPart) > 20 { + outputPart = outputPart[:20] + "..." } + parts = append(parts, outputPart) } - args := make([]string, 0) - for _, arg := range callExpr.Args { - if basicLit, ok := arg.(*gast.BasicLit); ok { - args = append(args, basicLit.Value) - } else if ident, ok := arg.(*gast.Ident); ok { - args = append(args, ident.Name) - } + name := strings.Join(parts, "_") + if len(name) > 50 { + name = name[:50] + "..." } - return fmt.Sprintf("%s(%s)", funcName, strings.Join(args, ", ")) + return name } // generateFallbackName generates a default name for a code block when no other name could be determined. @@ -254,3 +309,42 @@ func generateFallbackName(content string) string { } return "unnamed_block" } + +//////////////////// Execution Options //////////////////// + +type ExecutionOption struct { + Ignore bool + ShouldPanic string + // TODO: add more options +} + +func parseExecutionOptions(language string, firstLine []byte) ExecutionOption { + options := ExecutionOption{} + + parts := strings.Split(language, ",") + for _, option := range parts[1:] { // skip the first part which is the language + switch strings.TrimSpace(option) { + case "ignore": + options.Ignore = true + case "should_panic": + options.ShouldPanic = "" // specific panic message will be parsed later + } + } + + // parser options from the first line of the code block + if bytes.HasPrefix(firstLine, []byte("//")) { + re := regexp.MustCompile(`@(\w+)(?:="([^"]*)")?`) + matches := re.FindAllSubmatch(firstLine, -1) + for _, match := range matches { + switch string(match[1]) { + case "should_panic": + if match[2] != nil { + options.ShouldPanic = string(match[2]) + } + // TOOD: add more options + } + } + } + + return options +} diff --git a/gnovm/pkg/doctest/parser_test.go b/gnovm/pkg/doctest/parser_test.go index 57b2976d8fc..8e6d8a5d548 100644 --- a/gnovm/pkg/doctest/parser_test.go +++ b/gnovm/pkg/doctest/parser_test.go @@ -1,6 +1,7 @@ package doctest import ( + "reflect" "strings" "testing" ) @@ -251,60 +252,92 @@ func TestGenerateCodeBlockName(t *testing.T) { expected string }{ { - name: "Function name", + name: "Simple print function", content: ` package main -func TestFunction() { - println("Hello") -}`, - expected: "TestFunction()", +func main() { + println("Hello, World!") +} +// Output: +// Hello, World! +`, + expected: "Print_main_Hello, World!", }, { - name: "Main function only", + name: "Explicitly named code block", content: ` +// @test: specified package main func main() { - println("Hello") + println("specified") }`, - expected: "println(\"Hello\")", + expected: "specified", }, { - name: "No function", + name: "Simple calculation", content: ` package main -var x = 5 +import "math" + +func calculateArea(radius float64) float64 { + return math.Pi * radius * radius +} + +func main() { + println(calculateArea(5)) +} +// Output: +// 78.53981633974483 `, - expected: "x", + expected: "Calc_math_calculateArea_78.53981633974483", }, { - name: "Multiple functions", + name: "Test function", content: ` package main -func main() { - println("Hello") -} +import "testing" -func AnotherFunction() { - println("World") -}`, - expected: "AnotherFunction()", +func TestSquareRoot(t *testing.T) { + got := math.Sqrt(4) + if got != 2 { + t.Errorf("Sqrt(4) = %f; want 2", got) + } +} +`, + expected: "Test_testing_TestSquareRoot", }, { - name: "Empty content", - content: "", - expected: "unnamed_block", + name: "Multiple imports", + content: ` +package main + +import ( + "math" + "strings" +) + +func main() { + println(math.Pi) + println(strings.ToUpper("hello")) +} +// Output: +// 3.141592653589793 +// HELLO +`, + expected: "Print_math_strings_main_3.141592653589793", }, { - name: "Only comments", + name: "No function", content: ` -// This is a comment -// Another comment +package main + +var x = 5 `, - expected: "unnamed_block", + expected: "x", }, } @@ -318,49 +351,52 @@ func AnotherFunction() { } } -func TestGetCodeBlocks_Name(t *testing.T) { - markdown := ` -Some text here - -` + "```go" + ` -// @test: CustomNamedTest -func main() { - println("Custom named test") -} -` + "```" + ` - -Another paragraph - -` + "```go" + ` -func TestAutoNamed() { - println("Auto named test") -} -` + "```" + ` - -` + "```go" + ` -var x = 5 -` + "```" + ` -` - - codeBlocks := GetCodeBlocks(markdown) - - if len(codeBlocks) != 3 { - t.Fatalf("Expected 3 code blocks, got %d", len(codeBlocks)) - } - - // Test custom named block - if codeBlocks[0].name != "CustomNamedTest" { - t.Errorf("Expected first block name to be 'CustomNamedTest', got '%s'", codeBlocks[0].name) - } - - // Test auto named block with function - if codeBlocks[1].name != "func TestAutoNamed()..." { - t.Errorf("Expected second block name to be 'func TestAutoNamed()...', got '%s'", codeBlocks[1].name) +func TestParseExecutionOptions(t *testing.T) { + tests := []struct { + name string + language string + firstLine string + want ExecutionOption + }{ + { + name: "No options", + language: "go", + firstLine: "package main", + want: ExecutionOption{}, + }, + { + name: "Ignore option in language tag", + language: "go,ignore", + firstLine: "package main", + want: ExecutionOption{Ignore: true}, + }, + { + name: "Should panic option in language tag", + language: "go,should_panic", + firstLine: "package main", + want: ExecutionOption{ShouldPanic: ""}, + }, + { + name: "Should panic with message in comment", + language: "go,should_panic", + firstLine: "// @should_panic=\"division by zero\"", + want: ExecutionOption{ShouldPanic: "division by zero"}, + }, + { + name: "Multiple options", + language: "go,ignore,should_panic", + firstLine: "// @should_panic=\"runtime error\"", + want: ExecutionOption{Ignore: true, ShouldPanic: "runtime error"}, + }, } - // Test auto named block without function - if codeBlocks[2].name != "var x = 5" { - t.Errorf("Expected third block name to be 'var x = 5', got '%s'", codeBlocks[2].name) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseExecutionOptions(tt.language, []byte(tt.firstLine)) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("parseExecutionOptions() = %v, want %v", got, tt.want) + } + }) } } From 855bbc53d1e40afa3c64dc2e444a8a7a44158ffc Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Fri, 12 Jul 2024 20:58:29 +0900 Subject: [PATCH 27/37] add execution option and support regex match for expected output --- gnovm/cmd/gno/testdata/doctest/1.md | 86 +++++++++++++---- gnovm/pkg/doctest/exec.go | 62 +++++++++--- gnovm/pkg/doctest/exec_test.go | 141 ++++++++++++++++++++++++++++ gnovm/pkg/doctest/parser.go | 2 +- gnovm/pkg/doctest/parser_test.go | 51 ++++++++++ 5 files changed, 309 insertions(+), 33 deletions(-) diff --git a/gnovm/cmd/gno/testdata/doctest/1.md b/gnovm/cmd/gno/testdata/doctest/1.md index a187b5e6d21..398df7b7e6e 100644 --- a/gnovm/cmd/gno/testdata/doctest/1.md +++ b/gnovm/cmd/gno/testdata/doctest/1.md @@ -2,9 +2,24 @@ Gno Doctest is a tool that allows you to easily execute and test code blocks written in the Gno language. This tool offers a range of features, from simple code execution to complex package imports. -## 1. Basic Code Execution +## Basic Usage -Even the simplest form of code block can be easily executed. +To use Gno Doctest, run the following command: + +gno doctest -path -run + +- ``: Path to the markdown file containing Gno code blocks +- ``: Name of the code block to run (optional) + +For example, to run the code block named "print hello world" in the file "foo.md", use the following command: + +gno doctest -path foo.md -run "print hello world" + +## Features + +### 1. Basic Code Execution + +Gno Doctest can execute simple code blocks: ```go package main @@ -26,42 +41,42 @@ package main func main() { println("Hello, World!") } + +// Output: +// Hello, World! ``` Running this code will output "Hello, World!". ## 2. Using Standard Library Packages -Gno Doctest automatically recognizes and imports standard library packages. +Doctest supports automatic import and usage of standard library packages. -```go -package main - -import "std" +If run this code, doctest will automatically import the "std" package and execute the code. +```go +// @test: omit-package-declaration func main() { addr := std.GetOrigCaller() println(addr) } ``` -## 3. Utilizing Various Standard Libraries +The code above outputs the same result as the code below. -You can use multiple standard libraries simultaneously. - -```gno +```go +// @test: auto-import-package package main -import "strings" +import "std" func main() { - println(strings.ToUpper("Hello, World")) + addr := std.GetOrigCaller() + println(addr) } ``` -This example uses the ToUpper() function from the strings package to convert a string to uppercase. - -## 4. Automatic Package Import +## 3. Automatic Package Import One of the most powerful features of Gno Doctest is its ability to handle package declarations and imports automatically. @@ -74,11 +89,12 @@ func main() { In this code, the math and strings packages are not explicitly imported, but Doctest automatically recognizes and imports the necessary packages. -## 5. Omitting Package Declaration +## 4. Omitting Package Declaration -Doctest can even handle cases where the package main declaration is omitted. +Doctest can even handle cases where the `package` declaration is omitted. ```go +// @test: omit-top-level-package-declaration func main() { s := strings.ToUpper("Hello, World") println(s) @@ -92,3 +108,37 @@ This code runs normally without package declaration or import statements. Using Gno Doctest makes code execution and testing much more convenient. You can quickly run various Gno code snippets and check the results without complex setups. + +### 7. Execution Options + +Doctest supports special execution options: +Ignore Option +Use the ignore tag to skip execution of a code block: + +**Ignore Option** + +Use the ignore tag to skip execution of a code block: + +```go,ignore +// @ignore +func main() { + println("This won't be executed") +} +``` + +**Should Panic Option** + +Use the should_panic option to test for expected panics: + +```go,should_panic +// @test: division by zero +// @should_panic="division by zero" +func main() { + x := 10 / 0 + println(x) +} +``` + +## Conclusion + +Gno Doctest simplifies the process of executing and testing Gno code snippets. diff --git a/gnovm/pkg/doctest/exec.go b/gnovm/pkg/doctest/exec.go index 9bb1c39e94a..8fdae81818f 100644 --- a/gnovm/pkg/doctest/exec.go +++ b/gnovm/pkg/doctest/exec.go @@ -58,9 +58,16 @@ func hashCodeBlock(c codeBlock) string { // ExecuteCodeBlock executes a parsed code block and executes it in a gno VM. func ExecuteCodeBlock(c codeBlock, stdlibDir string) (string, error) { - if c.lang == "go" { - c.lang = "gno" - } else if c.lang != "gno" { + if c.options.Ignore { + return "IGNORED", nil + } + + // Extract the actual language from the lang field + lang := strings.Split(c.lang, ",")[0] + + if lang == "go" { + lang = "gno" + } else if lang != "gno" { return "", fmt.Errorf("unsupported language type: %s", c.lang) } @@ -105,7 +112,7 @@ func ExecuteCodeBlock(c codeBlock, stdlibDir string) (string, error) { mcw.MultiWrite() files := []*std.MemFile{ - {Name: fmt.Sprintf("%d.%s", c.index, c.lang), Body: src}, + {Name: fmt.Sprintf("%d.%s", c.index, lang), Body: src}, } addr := crypto.AddressFromPreimage([]byte("addr1")) @@ -116,6 +123,16 @@ func ExecuteCodeBlock(c codeBlock, stdlibDir string) (string, error) { msg2 := vm.NewMsgRun(addr, coins, files) res, err := vmk.Run(ctx, msg2) + if c.options.ShouldPanic != "" { + if err == nil { + return "", fmt.Errorf("expected panic with message: %s, but executed successfully", c.options.ShouldPanic) + } + if !strings.Contains(err.Error(), c.options.ShouldPanic) { + return "", fmt.Errorf("expected panic with message: %s, but got: %s", c.options.ShouldPanic, err.Error()) + } + return fmt.Sprintf("panicked as expected: %v", err), nil + } + if err != nil { return "", err } @@ -129,17 +146,34 @@ func ExecuteCodeBlock(c codeBlock, stdlibDir string) (string, error) { func compareResults(actual, expectedOutput, expectedError string) (string, error) { actual = strings.TrimSpace(actual) - expectedOutput = strings.TrimSpace(expectedOutput) - expectedError = strings.TrimSpace(expectedError) + expected := strings.TrimSpace(expectedOutput) + if expected == "" { + expected = strings.TrimSpace(expectedError) + } - if expectedOutput != "" { - if actual != expectedOutput { - return "", fmt.Errorf("expected output:\n%s\n\nbut got:\n%s", expectedOutput, actual) - } - } else if expectedError != "" { - if actual != expectedError { - return "", fmt.Errorf("expected error:\n%s\n\nbut got:\n%s", expectedError, actual) - } + if expected == "" { + return actual, nil + } + + if strings.HasPrefix(expected, "regex:") { + return compareRegex(actual, strings.TrimPrefix(expected, "regex:")) + } + + if actual != expected { + return "", fmt.Errorf("expected:\n%s\n\nbut got:\n%s", expected, actual) + } + + return actual, nil +} + +func compareRegex(actual, pattern string) (string, error) { + re, err := regexp.Compile(pattern) + if err != nil { + return "", fmt.Errorf("invalid regex pattern: %w", err) + } + + if !re.MatchString(actual) { + return "", fmt.Errorf("output did not match regex pattern:\npattern: %s\nactual: %s", pattern, actual) } return actual, nil diff --git a/gnovm/pkg/doctest/exec_test.go b/gnovm/pkg/doctest/exec_test.go index 10be70b855b..f833a74e91c 100644 --- a/gnovm/pkg/doctest/exec_test.go +++ b/gnovm/pkg/doctest/exec_test.go @@ -210,6 +210,70 @@ func main() { }, expectError: true, }, + { + name: "Ignored code block", + codeBlock: codeBlock{ + content: ` +package main + +func main() { + panic("This should not execute") +}`, + lang: "gno", + options: ExecutionOption{ + Ignore: true, + }, + }, + expectedResult: "IGNORED", + }, + { + name: "Should panic code block", + codeBlock: codeBlock{ + content: ` +package main + +func main() { + panic("Expected panic") +}`, + lang: "gno", + options: ExecutionOption{ + ShouldPanic: "Expected panic", + }, + }, + expectedResult: "panicked as expected: Expected panic", + }, + { + name: "Should panic but doesn't", + codeBlock: codeBlock{ + content: ` +package main + +func main() { + println("No panic") +}`, + lang: "gno", + options: ExecutionOption{ + ShouldPanic: "Expected panic", + }, + }, + expectError: true, + }, + { + name: "Should panic with specific message", + codeBlock: codeBlock{ + content: ` +package main + +func main() { + panic("Specific error message") +}`, + lang: "gno", + options: ExecutionOption{ + ShouldPanic: "Specific error message", + }, + }, + expectedResult: "panicked as expected: Specific error message", + }, } for _, tt := range tests { @@ -232,3 +296,80 @@ func main() { }) } } + +func TestCompareResults(t *testing.T) { + tests := []struct { + name string + actual string + expectedOutput string + expectedError string + wantErr bool + }{ + { + name: "Exact match", + actual: "Hello, World!", + expectedOutput: "Hello, World!", + }, + { + name: "Mismatch", + actual: "Hello, World!", + expectedOutput: "Hello, Gno!", + wantErr: true, + }, + { + name: "Regex match", + actual: "Hello, World!", + expectedOutput: "regex:Hello, \\w+!", + }, + { + name: "Numbers Regex match", + actual: "1234567890", + expectedOutput: "regex:\\d+", + }, + { + name: "Complex Regex match (e-mail format)", + actual: "foobar12456@somemail.com", + expectedOutput: "regex:[a-zA-Z0-9]+@[a-zA-Z0-9]+\\.[a-zA-Z0-9]+", + }, + { + name: "Error match", + actual: "Error: division by zero", + expectedError: "Error: division by zero", + }, + { + name: "Error mismatch", + actual: "Error: division by zero", + expectedError: "Error: null pointer", + wantErr: true, + }, + { + name: "Error regex match", + actual: "Error: division by zero", + expectedError: "regex:Error: .+", + }, + { + name: "Empty expected", + actual: "Hello, World!", + expectedOutput: "", + expectedError: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := compareResults(tt.actual, tt.expectedOutput, tt.expectedError) + if (err != nil) != tt.wantErr { + t.Errorf("compareResults() error = %v, wantErr %v", err, tt.wantErr) + return + } + if err != nil && tt.wantErr { + if tt.expectedOutput != "" && !strings.Contains(err.Error(), tt.expectedOutput) { + t.Errorf("compareResults() error = %v, should contain %v", err, tt.expectedOutput) + } + if tt.expectedError != "" && !strings.Contains(err.Error(), tt.expectedError) { + t.Errorf("compareResults() error = %v, should contain %v", err, tt.expectedError) + } + } + }) + } +} diff --git a/gnovm/pkg/doctest/parser.go b/gnovm/pkg/doctest/parser.go index 6da909ee893..27e0c5b2fbf 100644 --- a/gnovm/pkg/doctest/parser.go +++ b/gnovm/pkg/doctest/parser.go @@ -341,7 +341,7 @@ func parseExecutionOptions(language string, firstLine []byte) ExecutionOption { if match[2] != nil { options.ShouldPanic = string(match[2]) } - // TOOD: add more options + // TODO: add more options } } } diff --git a/gnovm/pkg/doctest/parser_test.go b/gnovm/pkg/doctest/parser_test.go index 8e6d8a5d548..8adb66519f5 100644 --- a/gnovm/pkg/doctest/parser_test.go +++ b/gnovm/pkg/doctest/parser_test.go @@ -400,6 +400,57 @@ func TestParseExecutionOptions(t *testing.T) { } } +func TestGetCodeBlocksWithOptions(t *testing.T) { + input := ` +Some text here + +` + "```go,ignore" + ` +// This block should be ignored +func main() { + panic("This should not execute") +} +` + "```" + ` + +Another paragraph + +` + "```go,should_panic" + ` +// @should_panic="runtime error: index out of range" +func main() { + arr := []int{1, 2, 3} + fmt.Println(arr[5]) +} +` + "```" + ` + +` + "```go" + ` +// Normal execution +func main() { + fmt.Println("Hello, World!") +} +` + "```" + ` +` + + blocks := GetCodeBlocks(input) + + if len(blocks) != 3 { + t.Fatalf("Expected 3 code blocks, got %d", len(blocks)) + } + + // Check the first block (ignore) + if !blocks[0].options.Ignore { + t.Errorf("Expected first block to be ignored") + } + + // Check the second block (should_panic) + if blocks[1].options.ShouldPanic != "runtime error: index out of range" { + t.Errorf("Expected second block to have ShouldPanic option set to 'runtime error: index out of range', got '%s'", blocks[1].options.ShouldPanic) + } + + // Check the third block (normal execution) + if blocks[2].options.Ignore || blocks[2].options.ShouldPanic != "" { + t.Errorf("Expected third block to have no special options") + } +} + // ignore whitespace in the source code func normalize(s string) string { return strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(s, "\n", ""), "\r", ""), "\t", ""), " ", "") From ad329eb7287b4d9c7a669fd455f032d6148b7def Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Mon, 15 Jul 2024 21:41:51 +0900 Subject: [PATCH 28/37] ignore non-go language, add timeout --- gnovm/cmd/gno/doctest.go | 49 +++++-- gnovm/cmd/gno/doctest_test.go | 199 ++++++++++++++-------------- gnovm/cmd/gno/testdata/doctest/1.md | 144 -------------------- gnovm/pkg/doctest/cache.go | 57 ++++++++ gnovm/pkg/doctest/exec.go | 89 +++++++++---- gnovm/pkg/doctest/exec_test.go | 126 ++++++++++++++++-- 6 files changed, 367 insertions(+), 297 deletions(-) delete mode 100644 gnovm/cmd/gno/testdata/doctest/1.md create mode 100644 gnovm/pkg/doctest/cache.go diff --git a/gnovm/cmd/gno/doctest.go b/gnovm/cmd/gno/doctest.go index 9d763c0b34a..e802c2ca1ff 100644 --- a/gnovm/cmd/gno/doctest.go +++ b/gnovm/cmd/gno/doctest.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "strings" + "time" dt "github.com/gnolang/gno/gnovm/pkg/doctest" "github.com/gnolang/gno/tm2/pkg/commands" @@ -13,8 +14,8 @@ import ( type doctestCfg struct { markdownPath string - // codeIndex int - runPattern string + runPattern string + timeout time.Duration } func newDoctestCmd(io commands.IO) *commands.Command { @@ -23,7 +24,7 @@ func newDoctestCmd(io commands.IO) *commands.Command { return commands.NewCommand( commands.Metadata{ Name: "doctest", - ShortUsage: "doctest -path [-run ]", + ShortUsage: "doctest -path [-run ] [-timeout ]", ShortHelp: "executes a specific code block from a markdown file", }, cfg, @@ -46,6 +47,11 @@ func (c *doctestCfg) RegisterFlags(fs *flag.FlagSet) { "", "pattern to match code block names", ) + fs.Duration( + "timeout", + c.timeout, + "timeout for code execution (e.g., 30s, 1m)", + ) } func execDoctest(cfg *doctestCfg, _ []string, io commands.IO) error { @@ -58,18 +64,37 @@ func execDoctest(cfg *doctestCfg, _ []string, io commands.IO) error { return fmt.Errorf("failed to read markdown file: %w", err) } - results, err := dt.ExecuteMatchingCodeBlock(content, cfg.runPattern) - if err != nil { - return fmt.Errorf("failed to execute code block: %w", err) + if cfg.timeout == 0 { + cfg.timeout = 30 * time.Second } + ctx, cancel := context.WithTimeout(context.Background(), cfg.timeout) + defer cancel() - if len(results) == 0 { - io.Println("No code blocks matched the pattern") - return nil - } + resultChan := make(chan []string) + errChan := make(chan error) - io.Println("Execution Result:") - io.Println(strings.Join(results, "\n\n")) + go func() { + results, err := dt.ExecuteMatchingCodeBlock(ctx, content, cfg.runPattern) + if err != nil { + errChan <- err + } else { + resultChan <- results + } + }() + + select { + case results := <-resultChan: + if len(results) == 0 { + io.Println("No code blocks matched the pattern") + return nil + } + io.Println("Execution Result:") + io.Println(strings.Join(results, "\n\n")) + case err := <-errChan: + return fmt.Errorf("failed to execute code block: %w", err) + case <-ctx.Done(): + return fmt.Errorf("execution timed out after %v", cfg.timeout) + } return nil } diff --git a/gnovm/cmd/gno/doctest_test.go b/gnovm/cmd/gno/doctest_test.go index 5efb8a60385..14e706f4e83 100644 --- a/gnovm/cmd/gno/doctest_test.go +++ b/gnovm/cmd/gno/doctest_test.go @@ -1,105 +1,98 @@ package main -// import ( -// "os" -// "testing" -// ) - -// func TestDoctest(t *testing.T) { -// tempDir, err := os.MkdirTemp("", "doctest-test") -// if err != nil { -// t.Fatalf("failed to create temp directory: %v", err) -// } -// defer os.RemoveAll(tempDir) - -// markdownContent := `# Go Code Examples - -// This document contains two simple examples written in Go. - -// ## Example 1: Fibonacci Sequence - -// The first example prints the first 10 numbers of the Fibonacci sequence. - -// ` + "```go" + ` -// // @test: Fibonacci -// package main - -// func main() { -// a, b := 0, 1 -// for i := 0; i < 10; i++ { -// println(a) -// a, b = b, a+b -// } -// } -// ` + "```" + ` - -// ## Example 2: String Reversal - -// The second example reverses a given string and prints it. - -// ` + "```go" + ` -// // @test: StringReversal -// package main - -// func main() { -// str := "Hello, Go!" -// runes := []rune(str) -// for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 { -// runes[i], runes[j] = runes[j], runes[i] -// } -// println(string(runes)) -// } -// ` + "```" + ` - -// These two examples demonstrate basic Go functionality without using concurrency, generics, or reflect. - -// ` + "## std Package" + ` -// ` + "```go" + ` -// // @test: StdPackage -// package main - -// import ( -// "std" -// ) - -// func main() { -// addr := std.GetOrigCaller() -// println(addr) -// } -// ` + "```" + ` -// ` - -// mdFile, err := os.CreateTemp(tempDir, "sample-*.md") -// if err != nil { -// t.Fatalf("failed to create temp file: %v", err) -// } -// defer mdFile.Close() - -// _, err = mdFile.WriteString(markdownContent) -// if err != nil { -// t.Fatalf("failed to write to temp file: %v", err) -// } - -// mdFilePath := mdFile.Name() - -// tc := []testMainCase{ -// { -// args: []string{"doctest", "-h"}, -// errShouldBe: "flag: help requested", -// }, -// { -// args: []string{"doctest", "-path", mdFilePath, "-run", "Fibonacci"}, -// stdoutShouldContain: "--- Fibonacci ---\n0\n1\n1\n2\n3\n5\n8\n13\n21\n34\n", -// }, -// { -// args: []string{"doctest", "-path", mdFilePath, "-run", "StringReversal"}, -// stdoutShouldContain: "--- StringReversal ---\n!oG ,olleH\n", -// }, -// { -// args: []string{"doctest", "-path", mdFilePath, "-run", "StdPackage"}, -// stdoutShouldContain: "--- StdPackage ---\ng14ch5q26mhx3jk5cxl88t278nper264ces4m8nt\n", -// }, -// } - -// testMainCaseRun(t, tc) -// } +import ( + "os" + "testing" +) + +func TestDoctest(t *testing.T) { + t.Skip("skipping test") + tempDir, err := os.MkdirTemp("", "doctest-test") + if err != nil { + t.Fatalf("failed to create temp directory: %v", err) + } + defer os.RemoveAll(tempDir) + + markdownContent := `# Go Code Examples + +This document contains two simple examples written in Go. + +## Example 1: Fibonacci Sequence + +The first example prints the first 10 numbers of the Fibonacci sequence. + +` + "```go" + ` +// @test: Fibonacci +package main + +func main() { + a, b := 0, 1 + for i := 0; i < 10; i++ { + println(a) + a, b = b, a+b + } +} +` + "```" + ` + +## Example 2: String Reversal + +The second example reverses a given string and prints it. + +` + "```go" + ` +// @test: StringReversal +package main + +func main() { + str := "Hello, Go!" + runes := []rune(str) + for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 { + runes[i], runes[j] = runes[j], runes[i] + } + println(string(runes)) +} +` + "```" + ` + +These two examples demonstrate basic Go functionality without using concurrency, generics, or reflect. + +` + "## std Package" + ` +` + "```go" + ` +// @test: StdPackage +package main + +import ( + "std" +) + +func main() { + addr := std.GetOrigCaller() + println(addr) +} +` + "```" + ` +` + + mdFile, err := os.CreateTemp(tempDir, "sample-*.md") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + defer mdFile.Close() + + _, err = mdFile.WriteString(markdownContent) + if err != nil { + t.Fatalf("failed to write to temp file: %v", err) + } + + mdFilePath := mdFile.Name() + + tc := []testMainCase{ + { + args: []string{"doctest", "-path", mdFilePath, "-run", "StringReversal"}, + stdoutShouldContain: "=== StringReversal ===\n\n!oG ,olleH", + }, + { + args: []string{"doctest", "-path", mdFilePath, "-run", "StdPackage"}, + stdoutShouldContain: "=== StdPackage ===\n\ng14ch5q26mhx3jk5cxl88t278nper264ces4m8nt", + }, + } + + testMainCaseRun(t, tc) +} diff --git a/gnovm/cmd/gno/testdata/doctest/1.md b/gnovm/cmd/gno/testdata/doctest/1.md deleted file mode 100644 index 398df7b7e6e..00000000000 --- a/gnovm/cmd/gno/testdata/doctest/1.md +++ /dev/null @@ -1,144 +0,0 @@ -# Gno Doctest: Easy Code Execution and Testing - -Gno Doctest is a tool that allows you to easily execute and test code blocks written in the Gno language. This tool offers a range of features, from simple code execution to complex package imports. - -## Basic Usage - -To use Gno Doctest, run the following command: - -gno doctest -path -run - -- ``: Path to the markdown file containing Gno code blocks -- ``: Name of the code block to run (optional) - -For example, to run the code block named "print hello world" in the file "foo.md", use the following command: - -gno doctest -path foo.md -run "print hello world" - -## Features - -### 1. Basic Code Execution - -Gno Doctest can execute simple code blocks: - -```go -package main - -func main() { - println("Hello, World!") -} - -// Output: -// Hello, World! -``` - -Doctest also recognizes that a block of code is a gno. The code below outputs the same result as the example above. - -```go -// @test: print hello world -package main - -func main() { - println("Hello, World!") -} - -// Output: -// Hello, World! -``` - -Running this code will output "Hello, World!". - -## 2. Using Standard Library Packages - -Doctest supports automatic import and usage of standard library packages. - -If run this code, doctest will automatically import the "std" package and execute the code. - -```go -// @test: omit-package-declaration -func main() { - addr := std.GetOrigCaller() - println(addr) -} -``` - -The code above outputs the same result as the code below. - -```go -// @test: auto-import-package -package main - -import "std" - -func main() { - addr := std.GetOrigCaller() - println(addr) -} -``` - -## 3. Automatic Package Import - -One of the most powerful features of Gno Doctest is its ability to handle package declarations and imports automatically. - -```go -func main() { - println(math.Pi) - println(strings.ToUpper("Hello, World")) -} -``` - -In this code, the math and strings packages are not explicitly imported, but Doctest automatically recognizes and imports the necessary packages. - -## 4. Omitting Package Declaration - -Doctest can even handle cases where the `package` declaration is omitted. - -```go -// @test: omit-top-level-package-declaration -func main() { - s := strings.ToUpper("Hello, World") - println(s) -} - -// Output: -// HELLO, WORLD -``` - -This code runs normally without package declaration or import statements. -Using Gno Doctest makes code execution and testing much more convenient. - -You can quickly run various Gno code snippets and check the results without complex setups. - -### 7. Execution Options - -Doctest supports special execution options: -Ignore Option -Use the ignore tag to skip execution of a code block: - -**Ignore Option** - -Use the ignore tag to skip execution of a code block: - -```go,ignore -// @ignore -func main() { - println("This won't be executed") -} -``` - -**Should Panic Option** - -Use the should_panic option to test for expected panics: - -```go,should_panic -// @test: division by zero -// @should_panic="division by zero" -func main() { - x := 10 / 0 - println(x) -} -``` - -## Conclusion - -Gno Doctest simplifies the process of executing and testing Gno code snippets. diff --git a/gnovm/pkg/doctest/cache.go b/gnovm/pkg/doctest/cache.go new file mode 100644 index 00000000000..9bf9267d49e --- /dev/null +++ b/gnovm/pkg/doctest/cache.go @@ -0,0 +1,57 @@ +package doctest + +import ( + "container/list" + "sync" +) + +const maxCacheSize = 25 + +type cacheItem struct { + key string + value string +} + +type lruCache struct { + capacity int + items map[string]*list.Element + order *list.List + mutex sync.RWMutex +} + +func newCache(capacity int) *lruCache { + return &lruCache{ + capacity: capacity, + items: make(map[string]*list.Element), + order: list.New(), + } +} + +func (c *lruCache) get(key string) (string, bool) { + c.mutex.RLock() + defer c.mutex.RUnlock() + if elem, ok := c.items[key]; ok { + c.order.MoveToFront(elem) + return elem.Value.(cacheItem).value, true + } + return "", false +} + +func (c *lruCache) set(key, value string) { + c.mutex.Lock() + defer c.mutex.Unlock() + if elem, ok := c.items[key]; ok { + c.order.MoveToFront(elem) + elem.Value = cacheItem{key, value} + } else { + if c.order.Len() >= c.capacity { + oldest := c.order.Back() + if oldest != nil { + delete(c.items, oldest.Value.(cacheItem).key) + c.order.Remove(oldest) + } + } + elem := c.order.PushFront(cacheItem{key, value}) + c.items[key] = elem + } +} diff --git a/gnovm/pkg/doctest/exec.go b/gnovm/pkg/doctest/exec.go index 8fdae81818f..2208c783de3 100644 --- a/gnovm/pkg/doctest/exec.go +++ b/gnovm/pkg/doctest/exec.go @@ -1,6 +1,7 @@ package doctest import ( + "context" "crypto/sha256" "encoding/hex" "fmt" @@ -31,6 +32,7 @@ const ( ASSERT = "assert" // Assert the result and expected output are equal ) +// GetStdlibsDir returns the path to the standard libraries directory. func GetStdlibsDir() string { _, filename, _, ok := runtime.Caller(0) if !ok { @@ -40,14 +42,7 @@ func GetStdlibsDir() string { } // cache stores the results of code execution. -var cache struct { - m map[string]string - sync.RWMutex -} - -func init() { - cache.m = make(map[string]string) -} +var cache = newCache(maxCacheSize) // hashCodeBlock generates a SHA256 hash for the given code block. func hashCodeBlock(c codeBlock) string { @@ -65,20 +60,17 @@ func ExecuteCodeBlock(c codeBlock, stdlibDir string) (string, error) { // Extract the actual language from the lang field lang := strings.Split(c.lang, ",")[0] + if lang != "go" && lang != "gno" { + return fmt.Sprintf("SKIPPED (Unsupported language: %s)", lang), nil + } + if lang == "go" { lang = "gno" - } else if lang != "gno" { - return "", fmt.Errorf("unsupported language type: %s", c.lang) } hashKey := hashCodeBlock(c) - // using cached result to avoid re-execution - cache.RLock() - result, found := cache.m[hashKey] - cache.RUnlock() - - if found { + if result, found := cache.get(hashKey); found { result, err := compareResults(result, c.expectedOutput, c.expectedError) if err != nil { return "", err @@ -137,13 +129,12 @@ func ExecuteCodeBlock(c codeBlock, stdlibDir string) (string, error) { return "", err } - cache.Lock() - cache.m[hashKey] = res - cache.Unlock() + cache.set(hashKey, res) return compareResults(res, c.expectedOutput, c.expectedError) } +// compareResults compares the actual output of code execution with the expected output or error. func compareResults(actual, expectedOutput, expectedError string) (string, error) { actual = strings.TrimSpace(actual) expected := strings.TrimSpace(expectedOutput) @@ -166,6 +157,8 @@ func compareResults(actual, expectedOutput, expectedError string) (string, error return actual, nil } +// compareRegex compares the actual output against a regex pattern. +// It returns an error if the regex is invalid or if the actual output does not match the pattern. func compareRegex(actual, pattern string) (string, error) { re, err := regexp.Compile(pattern) if err != nil { @@ -179,32 +172,72 @@ func compareRegex(actual, pattern string) (string, error) { return actual, nil } -func ExecuteMatchingCodeBlock(content string, pattern string) ([]string, error) { +// ExecuteMatchingCodeBlock executes all code blocks in the given content that match the given pattern. +// It returns a slice of execution results as strings and any error encountered during the execution. +func ExecuteMatchingCodeBlock(ctx context.Context, content string, pattern string) ([]string, error) { codeBlocks := GetCodeBlocks(content) var results []string for _, block := range codeBlocks { if matchPattern(block.name, pattern) { - result, err := ExecuteCodeBlock(block, GetStdlibsDir()) - if err != nil { - return nil, fmt.Errorf("failed to execute code block %s: %w", block.name, err) + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + result, err := ExecuteCodeBlock(block, GetStdlibsDir()) + if err != nil { + return nil, fmt.Errorf("failed to execute code block %s: %w", block.name, err) + } + results = append(results, fmt.Sprintf("\n=== %s ===\n\n%s\n", block.name, result)) } - results = append(results, fmt.Sprintf("\n=== %s ===\n\n%s", block.name, result)) } } return results, nil } +var ( + regexCache = make(map[string]*regexp.Regexp) + regexCacheMu sync.RWMutex +) + +// getCompiledRegex retrieves or compiles a regex pattern. +// it uses a cache to store compiled regex patterns for reuse. +func getCompiledRegex(pattern string) (*regexp.Regexp, error) { + regexCacheMu.RLock() + re, exists := regexCache[pattern] + regexCacheMu.RUnlock() + + if exists { + return re, nil + } + + regexCacheMu.Lock() + defer regexCacheMu.Unlock() + + // double-check in case another goroutine has compiled the regex + if re, exists = regexCache[pattern]; exists { + return re, nil + } + + compiledPattern := regexp.QuoteMeta(pattern) + compiledPattern = strings.ReplaceAll(compiledPattern, "\\*", ".*") + re, err := regexp.Compile(compiledPattern) + if err != nil { + return nil, err + } + + regexCache[pattern] = re + return re, nil +} + +// matchPattern checks if a name matches the specific pattern. func matchPattern(name, pattern string) bool { if pattern == "" { return true } - pattern = regexp.QuoteMeta(pattern) - pattern = strings.ReplaceAll(pattern, "\\*", ".*") - - re, err := regexp.Compile(pattern) + re, err := getCompiledRegex(pattern) if err != nil { return false } diff --git a/gnovm/pkg/doctest/exec_test.go b/gnovm/pkg/doctest/exec_test.go index f833a74e91c..00edccef60c 100644 --- a/gnovm/pkg/doctest/exec_test.go +++ b/gnovm/pkg/doctest/exec_test.go @@ -1,19 +1,15 @@ package doctest import ( + "context" + "reflect" "strings" "testing" + "time" ) -func clearCache() { - cache.Lock() - cache.m = make(map[string]string) - cache.Unlock() -} - func TestExecuteCodeBlockWithCache(t *testing.T) { t.Parallel() - clearCache() tests := []struct { name string @@ -54,8 +50,6 @@ func main() { } }) } - - clearCache() } func TestHashCodeBlock(t *testing.T) { @@ -208,7 +202,7 @@ func main() { content: `print("Hello")`, lang: "python", }, - expectError: true, + expectedResult: "SKIPPED (Unsupported language: python)", }, { name: "Ignored code block", @@ -373,3 +367,115 @@ func TestCompareResults(t *testing.T) { }) } } + +func TestExecuteMatchingCodeBlock(t *testing.T) { + testCases := []struct { + name string + content string + pattern string + expectedResult []string + expectError bool + }{ + { + name: "Single matching block", + content: ` +Some text here +` + "```go" + ` +// @test: test1 +func main() { + println("Hello, World!") +} +` + "```" + ` +More text +`, + pattern: "test1", + expectedResult: []string{"\n=== test1 ===\n\nHello, World!\n"}, + expectError: false, + }, + { + name: "Multiple matching blocks", + content: ` +` + "```go" + ` +// @test: test1 +func main() { + println("First") +} +` + "```" + ` +` + "```go" + ` +// @test: test2 +func main() { + println("Second") +} +` + "```" + ` +`, + pattern: "test*", + expectedResult: []string{"\n=== test1 ===\n\nFirst\n", "\n=== test2 ===\n\nSecond\n"}, + expectError: false, + }, + { + name: "No matching blocks", + content: ` +` + "```go" + ` +// @test: test1 +func main() { + println("Hello") +} +` + "```" + ` +`, + pattern: "nonexistent", + expectedResult: []string{}, + expectError: false, + }, + { + name: "Error in code block", + content: ` +` + "```go" + ` +// @test: error_test +func main() { + panic("This should cause an error") +} +` + "```" + ` +`, + pattern: "error_test", + expectError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + results, err := ExecuteMatchingCodeBlock(ctx, tc.content, tc.pattern) + + if tc.expectError { + if err == nil { + t.Errorf("Expected an error, but got none") + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + if len(results) == 0 && len(tc.expectedResult) == 0 { + // do nothing + } else if !reflect.DeepEqual(results, tc.expectedResult) { + t.Errorf("Expected results %v, but got %v", tc.expectedResult, results) + } + } + + for _, expected := range tc.expectedResult { + found := false + for _, result := range results { + if strings.Contains(result, strings.TrimSpace(expected)) { + found = true + break + } + } + if !found { + t.Errorf("Expected result not found: %s", expected) + } + } + }) + } +} From 1f7f238cd53456ecfd2620969ab4144af79692f1 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Fri, 2 Aug 2024 14:43:56 +0900 Subject: [PATCH 29/37] Update gnovm/pkg/doctest/analyzer.go Co-authored-by: deelawn --- gnovm/pkg/doctest/analyzer.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gnovm/pkg/doctest/analyzer.go b/gnovm/pkg/doctest/analyzer.go index 09d1afdc362..0a4f3f8598e 100644 --- a/gnovm/pkg/doctest/analyzer.go +++ b/gnovm/pkg/doctest/analyzer.go @@ -54,7 +54,7 @@ func analyzeAndModifyCode(code string) (string, error) { fset := token.NewFileSet() node, err := parser.ParseFile(fset, "", code, parser.AllErrors) if err != nil { - // append package main to the code and try to parse again + // Prepend package main to the code and try to parse again. node, err = parser.ParseFile(fset, "", "package main\n"+code, parser.ParseComments) if err != nil { return "", fmt.Errorf("failed to parse code: %w", err) From 1bb104a089b07bfbb9ab0d58ff85cd04570851b3 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Fri, 2 Aug 2024 14:45:48 +0900 Subject: [PATCH 30/37] Apply suggestions from code review Co-authored-by: deelawn --- gnovm/pkg/doctest/parser.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/gnovm/pkg/doctest/parser.go b/gnovm/pkg/doctest/parser.go index 27e0c5b2fbf..e0829ada1aa 100644 --- a/gnovm/pkg/doctest/parser.go +++ b/gnovm/pkg/doctest/parser.go @@ -52,7 +52,7 @@ func GetCodeBlocks(body string) []codeBlock { func createCodeBlock(node *mast.FencedCodeBlock, body string, index int) codeBlock { var buf bytes.Buffer lines := node.Lines() - for i := 0; i < node.Lines().Len(); i++ { + for i := 0; i < lines.Len(); i++ { line := lines.At(i) buf.Write([]byte(body[line.Start:line.Stop])) } @@ -98,7 +98,7 @@ func parseExpectedResults(content string) (string, string, error) { lines := strings.Split(section, "\n") var cleanedLines []string for _, line := range lines { - trimmedLine := strings.TrimPrefix(line, "//") + trimmedLine := strings.TrimSpace(strings.TrimPrefix(line, "//")) if len(trimmedLine) > 0 && trimmedLine[0] == ' ' { trimmedLine = trimmedLine[1:] } @@ -312,14 +312,14 @@ func generateFallbackName(content string) string { //////////////////// Execution Options //////////////////// -type ExecutionOption struct { +type ExecutionOptions struct { Ignore bool ShouldPanic string // TODO: add more options } func parseExecutionOptions(language string, firstLine []byte) ExecutionOption { - options := ExecutionOption{} + var options ExecutionOptions parts := strings.Split(language, ",") for _, option := range parts[1:] { // skip the first part which is the language From 6bac3adcea48204cfb2dff4adcaad3d870afa521 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Fri, 2 Aug 2024 15:38:27 +0900 Subject: [PATCH 31/37] resolve suggestions (doctest, exec, parser) --- gnovm/cmd/gno/doctest.go | 5 +-- gnovm/cmd/gno/doctest_test.go | 1 - gnovm/pkg/doctest/exec.go | 22 ++++++----- gnovm/pkg/doctest/exec_test.go | 14 +++---- gnovm/pkg/doctest/parser.go | 64 ++++++++++++++++++-------------- gnovm/pkg/doctest/parser_test.go | 49 +++++++++++++----------- gnovm/pkg/gnolang/nodes_test.go | 18 ++++----- 7 files changed, 94 insertions(+), 79 deletions(-) diff --git a/gnovm/cmd/gno/doctest.go b/gnovm/cmd/gno/doctest.go index e802c2ca1ff..27a31b5b1d4 100644 --- a/gnovm/cmd/gno/doctest.go +++ b/gnovm/cmd/gno/doctest.go @@ -49,7 +49,7 @@ func (c *doctestCfg) RegisterFlags(fs *flag.FlagSet) { ) fs.Duration( "timeout", - c.timeout, + time.Second*30, "timeout for code execution (e.g., 30s, 1m)", ) } @@ -64,9 +64,6 @@ func execDoctest(cfg *doctestCfg, _ []string, io commands.IO) error { return fmt.Errorf("failed to read markdown file: %w", err) } - if cfg.timeout == 0 { - cfg.timeout = 30 * time.Second - } ctx, cancel := context.WithTimeout(context.Background(), cfg.timeout) defer cancel() diff --git a/gnovm/cmd/gno/doctest_test.go b/gnovm/cmd/gno/doctest_test.go index 14e706f4e83..9ce9fc8942e 100644 --- a/gnovm/cmd/gno/doctest_test.go +++ b/gnovm/cmd/gno/doctest_test.go @@ -6,7 +6,6 @@ import ( ) func TestDoctest(t *testing.T) { - t.Skip("skipping test") tempDir, err := os.MkdirTemp("", "doctest-test") if err != nil { t.Fatalf("failed to create temp directory: %v", err) diff --git a/gnovm/pkg/doctest/exec.go b/gnovm/pkg/doctest/exec.go index 2208c783de3..7741c9a25f7 100644 --- a/gnovm/pkg/doctest/exec.go +++ b/gnovm/pkg/doctest/exec.go @@ -32,6 +32,11 @@ const ( ASSERT = "assert" // Assert the result and expected output are equal ) +const ( + goLang = "go" + gnoLang = "gno" +) + // GetStdlibsDir returns the path to the standard libraries directory. func GetStdlibsDir() string { _, filename, _, ok := runtime.Caller(0) @@ -60,12 +65,12 @@ func ExecuteCodeBlock(c codeBlock, stdlibDir string) (string, error) { // Extract the actual language from the lang field lang := strings.Split(c.lang, ",")[0] - if lang != "go" && lang != "gno" { + if lang != goLang && lang != gnoLang { return fmt.Sprintf("SKIPPED (Unsupported language: %s)", lang), nil } - if lang == "go" { - lang = "gno" + if lang == goLang { + lang = gnoLang } hashKey := hashCodeBlock(c) @@ -111,16 +116,15 @@ func ExecuteCodeBlock(c codeBlock, stdlibDir string) (string, error) { acc := acck.NewAccountWithAddress(ctx, addr) acck.SetAccount(ctx, acc) - coins := std.MustParseCoins("") - msg2 := vm.NewMsgRun(addr, coins, files) + msg2 := vm.NewMsgRun(addr, std.Coins{}, files) res, err := vmk.Run(ctx, msg2) - if c.options.ShouldPanic != "" { + if c.options.PanicMessage != "" { if err == nil { - return "", fmt.Errorf("expected panic with message: %s, but executed successfully", c.options.ShouldPanic) + return "", fmt.Errorf("expected panic with message: %s, but executed successfully", c.options.PanicMessage) } - if !strings.Contains(err.Error(), c.options.ShouldPanic) { - return "", fmt.Errorf("expected panic with message: %s, but got: %s", c.options.ShouldPanic, err.Error()) + if !strings.Contains(err.Error(), c.options.PanicMessage) { + return "", fmt.Errorf("expected panic with message: %s, but got: %s", c.options.PanicMessage, err.Error()) } return fmt.Sprintf("panicked as expected: %v", err), nil } diff --git a/gnovm/pkg/doctest/exec_test.go b/gnovm/pkg/doctest/exec_test.go index 00edccef60c..9fc5b78eb93 100644 --- a/gnovm/pkg/doctest/exec_test.go +++ b/gnovm/pkg/doctest/exec_test.go @@ -214,7 +214,7 @@ func main() { panic("This should not execute") }`, lang: "gno", - options: ExecutionOption{ + options: ExecutionOptions{ Ignore: true, }, }, @@ -230,8 +230,8 @@ func main() { panic("Expected panic") }`, lang: "gno", - options: ExecutionOption{ - ShouldPanic: "Expected panic", + options: ExecutionOptions{ + PanicMessage: "Expected panic", }, }, expectedResult: "panicked as expected: Expected panic", @@ -246,8 +246,8 @@ func main() { println("No panic") }`, lang: "gno", - options: ExecutionOption{ - ShouldPanic: "Expected panic", + options: ExecutionOptions{ + PanicMessage: "Expected panic", }, }, expectError: true, @@ -262,8 +262,8 @@ func main() { panic("Specific error message") }`, lang: "gno", - options: ExecutionOption{ - ShouldPanic: "Specific error message", + options: ExecutionOptions{ + PanicMessage: "Specific error message", }, }, expectedResult: "panicked as expected: Specific error message", diff --git a/gnovm/pkg/doctest/parser.go b/gnovm/pkg/doctest/parser.go index e0829ada1aa..8d17dfdfaa6 100644 --- a/gnovm/pkg/doctest/parser.go +++ b/gnovm/pkg/doctest/parser.go @@ -1,6 +1,7 @@ package doctest import ( + "bufio" "bytes" "go/ast" "go/parser" @@ -23,7 +24,7 @@ type codeBlock struct { expectedOutput string // The expected output of the code block. expectedError string // The expected error of the code block. name string // The name of the code block. - options ExecutionOption + options ExecutionOptions } // GetCodeBlocks parses the provided markdown text to extract all embedded code blocks. @@ -38,7 +39,7 @@ func GetCodeBlocks(body string) []codeBlock { if entering { if cb, ok := n.(*mast.FencedCodeBlock); ok { codeBlock := createCodeBlock(cb, body, len(codeBlocks)) - codeBlock.name = generateCodeBlockName(codeBlock.content) + codeBlock.name = generateCodeBlockName(codeBlock.content, codeBlock.expectedOutput) codeBlocks = append(codeBlocks, codeBlock) } } @@ -142,9 +143,10 @@ func parseExpectedResults(content string) (string, string, error) { // If not found, it analyzes the code structure to create meaningful name. // The name is constructed based on the code's prefix (Test, Print or Calc), // imported packages, main identifier, and expected output. -func generateCodeBlockName(content string) string { - lines := strings.Split(content, "\n") - for _, line := range lines { +func generateCodeBlockName(content string, expectedOutput string) string { + scanner := bufio.NewScanner(strings.NewReader(content)) + for scanner.Scan() { + line := scanner.Text() if strings.HasPrefix(strings.TrimSpace(line), "// @test:") { return strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "// @test:")) } @@ -158,7 +160,6 @@ func generateCodeBlockName(content string) string { prefix := determinePrefix(f) imports := extractImports(f) - expectedOutput, _, _ := parseExpectedResults(content) mainIdentifier := extractMainIdentifier(f) name := constructName(prefix, imports, expectedOutput, mainIdentifier) @@ -191,34 +192,34 @@ func determinePrefix(f *ast.File) string { // containsPrintStmt checks if the given function declaration contains // any print or println statements. func containsPrintStmt(fn *ast.FuncDecl) bool { - var yes bool + var hasPrintStmt bool ast.Inspect(fn, func(n ast.Node) bool { if call, ok := n.(*ast.CallExpr); ok { if ident, ok := call.Fun.(*ast.Ident); ok { if ident.Name == "println" || ident.Name == "print" { - yes = true + hasPrintStmt = true return false } } } return true }) - return yes + return hasPrintStmt } // containsCalculation checks if the given function declaration contains // any binary or unary expressions, which are indicative of calculations. func containsCalculation(fn *ast.FuncDecl) bool { - var yes bool + var hasCalcExpr bool ast.Inspect(fn, func(n ast.Node) bool { switch n.(type) { case *ast.BinaryExpr, *ast.UnaryExpr: - yes = true + hasCalcExpr = true return false } return true }) - return yes + return hasCalcExpr } // extractImports extracts the names of imported packages from the AST @@ -229,11 +230,11 @@ func extractImports(f *ast.File) []string { for _, imp := range f.Imports { if imp.Name != nil { imports = append(imports, imp.Name.Name) - } else { - path := strings.Trim(imp.Path.Value, `"`) - parts := strings.Split(path, "/") - imports = append(imports, parts[len(parts)-1]) + continue } + path := strings.Trim(imp.Path.Value, `"`) + parts := strings.Split(path, "/") + imports = append(imports, parts[len(parts)-1]) } return imports } @@ -271,9 +272,6 @@ func constructName( if prefix != "" { parts = append(parts, prefix) } - if len(imports) > 0 { - parts = append(parts, strings.Join(imports, "_")) - } if mainIdentifier != "" { parts = append(parts, mainIdentifier) } @@ -286,6 +284,15 @@ func constructName( parts = append(parts, outputPart) } + // Add imports last, limiting to a certain number of characters + if len(imports) > 0 { + importString := strings.Join(imports, "_") + if len(importString) > 30 { + importString = importString[:30] + "..." + } + parts = append(parts, importString) + } + name := strings.Join(parts, "_") if len(name) > 50 { name = name[:50] + "..." @@ -297,9 +304,9 @@ func constructName( // generateFallbackName generates a default name for a code block when no other name could be determined. // It uses the first significant line of the code that is not a comment or package declaration. func generateFallbackName(content string) string { - lines := strings.Split(content, "\n") - for _, line := range lines { - trimmed := strings.TrimSpace(line) + scanner := bufio.NewScanner(strings.NewReader(content)) + for scanner.Scan() { + trimmed := strings.TrimSpace(scanner.Text()) if trimmed != "" && !strings.HasPrefix(trimmed, "//") && trimmed != "package main" { if len(trimmed) > 20 { return trimmed[:20] + "..." @@ -313,12 +320,12 @@ func generateFallbackName(content string) string { //////////////////// Execution Options //////////////////// type ExecutionOptions struct { - Ignore bool - ShouldPanic string + Ignore bool + PanicMessage string // TODO: add more options } -func parseExecutionOptions(language string, firstLine []byte) ExecutionOption { +func parseExecutionOptions(language string, firstLine []byte) ExecutionOptions { var options ExecutionOptions parts := strings.Split(language, ",") @@ -327,19 +334,22 @@ func parseExecutionOptions(language string, firstLine []byte) ExecutionOption { case "ignore": options.Ignore = true case "should_panic": - options.ShouldPanic = "" // specific panic message will be parsed later + // specific panic message will be parsed later } } // parser options from the first line of the code block if bytes.HasPrefix(firstLine, []byte("//")) { + // parse execution options from the first line of the code block + // e.g. // @should_panic="some panic message here" + // |-option name-||-----option value-----| re := regexp.MustCompile(`@(\w+)(?:="([^"]*)")?`) matches := re.FindAllSubmatch(firstLine, -1) for _, match := range matches { switch string(match[1]) { case "should_panic": if match[2] != nil { - options.ShouldPanic = string(match[2]) + options.PanicMessage = string(match[2]) } // TODO: add more options } diff --git a/gnovm/pkg/doctest/parser_test.go b/gnovm/pkg/doctest/parser_test.go index 8adb66519f5..95ea1a86787 100644 --- a/gnovm/pkg/doctest/parser_test.go +++ b/gnovm/pkg/doctest/parser_test.go @@ -190,7 +190,7 @@ fmt.Println(" Indented") // Output: // Indented `, - wantOutput: " Indented", + wantOutput: "Indented", wantError: "", }, { @@ -247,9 +247,10 @@ func main() { func TestGenerateCodeBlockName(t *testing.T) { tests := []struct { - name string - content string - expected string + name string + content string + output string + expectedGenerateName string }{ { name: "Simple print function", @@ -262,7 +263,8 @@ func main() { // Output: // Hello, World! `, - expected: "Print_main_Hello, World!", + output: "Hello, World!", + expectedGenerateName: "Print_main_Hello, World!", }, { name: "Explicitly named code block", @@ -273,7 +275,8 @@ package main func main() { println("specified") }`, - expected: "specified", + output: "specified", + expectedGenerateName: "specified", }, { name: "Simple calculation", @@ -292,7 +295,8 @@ func main() { // Output: // 78.53981633974483 `, - expected: "Calc_math_calculateArea_78.53981633974483", + output: "78.53981633974483", + expectedGenerateName: "Calc_calculateArea_78.53981633974483_math", }, { name: "Test function", @@ -308,7 +312,7 @@ func TestSquareRoot(t *testing.T) { } } `, - expected: "Test_testing_TestSquareRoot", + expectedGenerateName: "Test_TestSquareRoot_testing", }, { name: "Multiple imports", @@ -328,7 +332,8 @@ func main() { // 3.141592653589793 // HELLO `, - expected: "Print_math_strings_main_3.141592653589793", + output: "3.141592653589793\nHELLO", + expectedGenerateName: "Print_main_3.141592653589793_math_strings", }, { name: "No function", @@ -337,15 +342,15 @@ package main var x = 5 `, - expected: "x", + expectedGenerateName: "x", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := generateCodeBlockName(tt.content) - if result != tt.expected { - t.Errorf("generateCodeBlockName() = %v, want %v", result, tt.expected) + result := generateCodeBlockName(tt.content, tt.output) + if result != tt.expectedGenerateName { + t.Errorf("generateCodeBlockName() = %v, want %v", result, tt.expectedGenerateName) } }) } @@ -356,37 +361,37 @@ func TestParseExecutionOptions(t *testing.T) { name string language string firstLine string - want ExecutionOption + want ExecutionOptions }{ { name: "No options", language: "go", firstLine: "package main", - want: ExecutionOption{}, + want: ExecutionOptions{}, }, { name: "Ignore option in language tag", language: "go,ignore", firstLine: "package main", - want: ExecutionOption{Ignore: true}, + want: ExecutionOptions{Ignore: true}, }, { name: "Should panic option in language tag", language: "go,should_panic", firstLine: "package main", - want: ExecutionOption{ShouldPanic: ""}, + want: ExecutionOptions{PanicMessage: ""}, }, { name: "Should panic with message in comment", language: "go,should_panic", firstLine: "// @should_panic=\"division by zero\"", - want: ExecutionOption{ShouldPanic: "division by zero"}, + want: ExecutionOptions{PanicMessage: "division by zero"}, }, { name: "Multiple options", language: "go,ignore,should_panic", firstLine: "// @should_panic=\"runtime error\"", - want: ExecutionOption{Ignore: true, ShouldPanic: "runtime error"}, + want: ExecutionOptions{Ignore: true, PanicMessage: "runtime error"}, }, } @@ -441,12 +446,12 @@ func main() { } // Check the second block (should_panic) - if blocks[1].options.ShouldPanic != "runtime error: index out of range" { - t.Errorf("Expected second block to have ShouldPanic option set to 'runtime error: index out of range', got '%s'", blocks[1].options.ShouldPanic) + if blocks[1].options.PanicMessage != "runtime error: index out of range" { + t.Errorf("Expected second block to have ShouldPanic option set to 'runtime error: index out of range', got '%s'", blocks[1].options.PanicMessage) } // Check the third block (normal execution) - if blocks[2].options.Ignore || blocks[2].options.ShouldPanic != "" { + if blocks[2].options.Ignore || blocks[2].options.PanicMessage != "" { t.Errorf("Expected third block to have no special options") } } diff --git a/gnovm/pkg/gnolang/nodes_test.go b/gnovm/pkg/gnolang/nodes_test.go index 4f314fcd814..c602310820b 100644 --- a/gnovm/pkg/gnolang/nodes_test.go +++ b/gnovm/pkg/gnolang/nodes_test.go @@ -1,12 +1,12 @@ +package gnolang + import ( "math" - "os" + "os" "path/filepath" "testing" - "github.com/gnolang/gno/gnovm/pkg/gnolang" - - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -29,12 +29,12 @@ func TestStaticBlock_Define2_MaxNames(t *testing.T) { t.Errorf("expected panic when exceeding maximum number of names") }() - staticBlock := new(gnolang.StaticBlock) + staticBlock := new(StaticBlock) staticBlock.NumNames = math.MaxUint16 - 1 - staticBlock.Names = make([]gnolang.Name, staticBlock.NumNames) + staticBlock.Names = make([]Name, staticBlock.NumNames) // Adding one more is okay. - staticBlock.Define2(false, gnolang.Name("a"), gnolang.BoolType, gnolang.TypedValue{T: gnolang.BoolType}) + staticBlock.Define2(false, Name("a"), BoolType, TypedValue{T: BoolType}) if staticBlock.NumNames != math.MaxUint16 { t.Errorf("expected NumNames to be %d, got %d", math.MaxUint16, staticBlock.NumNames) } @@ -43,7 +43,7 @@ func TestStaticBlock_Define2_MaxNames(t *testing.T) { } // This one should panic because the maximum number of names has been reached. - staticBlock.Define2(false, gnolang.Name("a"), gnolang.BoolType, gnolang.TypedValue{T: gnolang.BoolType}) + staticBlock.Define2(false, Name("a"), BoolType, TypedValue{T: BoolType}) } func TestReadMemPackage(t *testing.T) { @@ -83,4 +83,4 @@ func TestReadMemPackage(t *testing.T) { assert.Panics(t, func() { ReadMemPackage("/non/existent/dir", "testpkg") }, "Expected panic for non-existent directory") -} \ No newline at end of file +} From 4978329b768d8849c8daad0d201e09c40ea10dd3 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Fri, 2 Aug 2024 23:34:22 +0900 Subject: [PATCH 32/37] fix: partial exec, parser --- gnovm/cmd/gno/doctest.go | 3 +- gnovm/cmd/gno/testdata/doctest/test.md | 131 +++++++++++++++++++++++++ gnovm/pkg/doctest/analyzer.go | 20 ++-- gnovm/pkg/doctest/exec.go | 6 +- gnovm/pkg/doctest/parser.go | 45 +++++---- 5 files changed, 172 insertions(+), 33 deletions(-) create mode 100644 gnovm/cmd/gno/testdata/doctest/test.md diff --git a/gnovm/cmd/gno/doctest.go b/gnovm/cmd/gno/doctest.go index 27a31b5b1d4..7ffba92714f 100644 --- a/gnovm/cmd/gno/doctest.go +++ b/gnovm/cmd/gno/doctest.go @@ -47,7 +47,8 @@ func (c *doctestCfg) RegisterFlags(fs *flag.FlagSet) { "", "pattern to match code block names", ) - fs.Duration( + fs.DurationVar( + &c.timeout, "timeout", time.Second*30, "timeout for code execution (e.g., 30s, 1m)", diff --git a/gnovm/cmd/gno/testdata/doctest/test.md b/gnovm/cmd/gno/testdata/doctest/test.md new file mode 100644 index 00000000000..60f72287f40 --- /dev/null +++ b/gnovm/cmd/gno/testdata/doctest/test.md @@ -0,0 +1,131 @@ +# Gno Doctest: Easy Code Execution and Testing + +Gno Doctest is a tool that allows you to easily execute and test code blocks written in the Gno language. This tool offers a range of features, from simple code execution to complex package imports. + +## Basic Usage + +To use Gno Doctest, run the following command: + +gno doctest -path -run + +- ``: Path to the markdown file containing Gno code blocks +- ``: Name of the code block to run (optional) + +For example, to run the code block named "print hello world" in the file "foo.md", use the following command: + +gno doctest -path foo.md -run "print hello world" + +## Features + +### 1. Basic Code Execution + +Gno Doctest can execute simple code blocks: + +```go +package main + +func main() { + println("Hello, World!") +} + +// Output: +// Hello, World! +``` + +Doctest also recognizes that a block of code is a gno. The code below outputs the same result as the example above. + +```go +// @test: print hello world +package main + +func main() { + println("Hello, World!") +} + +// Output: +// Hello, World! +``` + +Running this code will output "Hello, World!". + +## 2. Using Standard Library Packages + +Doctest supports automatic import and usage of standard library packages. + +If run this code, doctest will automatically import the "std" package and execute the code. + +```go +// @test: omit-package-declaration +func main() { + addr := std.GetOrigCaller() + println(addr) +} +``` + +The code above outputs the same result as the code below. + +```go +// @test: auto-import-package +package main + +import "std" + +func main() { + addr := std.GetOrigCaller() + println(addr) +} +``` + +## 3. Automatic Package Import + +One of the most powerful features of Gno Doctest is its ability to handle package declarations and imports automatically. + +```go +func main() { + println(math.Pi) + println(strings.ToUpper("Hello, World")) +} +``` + +In this code, the math and strings packages are not explicitly imported, but Doctest automatically recognizes and imports the necessary packages. + +## 4. Omitting Package Declaration + +Doctest can even handle cases where the `package` declaration is omitted. + +```go +// @test: omit-top-level-package-declaration +func main() { + s := strings.ToUpper("Hello, World") + println(s) +} + +// Output: +// HELLO, WORLD +``` + +This code runs normally without package declaration or import statements. +Using Gno Doctest makes code execution and testing much more convenient. + +You can quickly run various Gno code snippets and check the results without complex setups. + +### 7. Execution Options + +Doctest supports special execution options: +Ignore Option +Use the ignore tag to skip execution of a code block: + +**Ignore Option** + +Use the ignore tag to skip execution of a code block: + +```go,ignore +// @ignore +func main() { + println("This won't be executed") +} +``` + +## Conclusion + +Gno Doctest simplifies the process of executing and testing Gno code snippets. diff --git a/gnovm/pkg/doctest/analyzer.go b/gnovm/pkg/doctest/analyzer.go index 0a4f3f8598e..21207a298c7 100644 --- a/gnovm/pkg/doctest/analyzer.go +++ b/gnovm/pkg/doctest/analyzer.go @@ -61,10 +61,14 @@ func analyzeAndModifyCode(code string) (string, error) { } } - ensurePackageDeclaration(node) - if exist := ensureMainFunction(node); !exist { + if node.Name == nil { + node.Name = ast.NewIdent("main") + } + + if !hasMainFunction(node) { return "", fmt.Errorf("main function is missing") } + updateImports(node) src, err := codePrettier(fset, node) @@ -75,17 +79,9 @@ func analyzeAndModifyCode(code string) (string, error) { return src, nil } -// ensurePackageDeclaration ensures the code block has a package declaration. -// by adding a package main declaration if missing. -func ensurePackageDeclaration(node *ast.File) { - if node.Name == nil { - node.Name = ast.NewIdent("main") - } -} - -// ensureMainFunction checks if a main function exists in the AST. +// hasMainFunction checks if a main function exists in the AST. // It returns an error if the main function is missing. -func ensureMainFunction(node *ast.File) bool { +func hasMainFunction(node *ast.File) bool { for _, decl := range node.Decls { if fn, isFn := decl.(*ast.FuncDecl); isFn && fn.Name.Name == "main" { return true diff --git a/gnovm/pkg/doctest/exec.go b/gnovm/pkg/doctest/exec.go index 7741c9a25f7..1cdb2e8105a 100644 --- a/gnovm/pkg/doctest/exec.go +++ b/gnovm/pkg/doctest/exec.go @@ -224,9 +224,9 @@ func getCompiledRegex(pattern string) (*regexp.Regexp, error) { return re, nil } - compiledPattern := regexp.QuoteMeta(pattern) - compiledPattern = strings.ReplaceAll(compiledPattern, "\\*", ".*") - re, err := regexp.Compile(compiledPattern) + compiledPattern := regexp.QuoteMeta(pattern) // Escape all regex meta characters + compiledPattern = strings.ReplaceAll(compiledPattern, "\\*", ".*") // Replace escaped `*` with `.*` to match any character + re, err := regexp.Compile(compiledPattern) // Compile the converted pattern if err != nil { return nil, err } diff --git a/gnovm/pkg/doctest/parser.go b/gnovm/pkg/doctest/parser.go index 8d17dfdfaa6..fe13efaae86 100644 --- a/gnovm/pkg/doctest/parser.go +++ b/gnovm/pkg/doctest/parser.go @@ -3,6 +3,7 @@ package doctest import ( "bufio" "bytes" + "fmt" "go/ast" "go/parser" "go/token" @@ -95,25 +96,13 @@ func parseExpectedResults(content string) (string, string, error) { var outputs, errors []string - cleanSection := func(section string) string { - lines := strings.Split(section, "\n") - var cleanedLines []string - for _, line := range lines { - trimmedLine := strings.TrimSpace(strings.TrimPrefix(line, "//")) - if len(trimmedLine) > 0 && trimmedLine[0] == ' ' { - trimmedLine = trimmedLine[1:] - } - if trimmedLine != "" { - cleanedLines = append(cleanedLines, trimmedLine) - } - } - return strings.Join(cleanedLines, "\n") - } - outputMatches := outputRegex.FindAllStringSubmatch(content, -1) for _, match := range outputMatches { if len(match) > 1 { - cleaned := cleanSection(match[1]) + cleaned, err := cleanSection(match[1]) + if err != nil { + return "", "", err + } if cleaned != "" { outputs = append(outputs, cleaned) } @@ -123,7 +112,10 @@ func parseExpectedResults(content string) (string, string, error) { errorMatches := errorRegex.FindAllStringSubmatch(content, -1) for _, match := range errorMatches { if len(match) > 1 { - cleaned := cleanSection(match[1]) + cleaned, err := cleanSection(match[1]) + if err != nil { + return "", "", err + } if cleaned != "" { errors = append(errors, cleaned) } @@ -136,6 +128,25 @@ func parseExpectedResults(content string) (string, string, error) { return expectedOutput, expectedError, nil } +func cleanSection(section string) (string, error) { + scanner := bufio.NewScanner(strings.NewReader(section)) + var cleanedLines []string + + for scanner.Scan() { + line := strings.TrimSpace(strings.TrimPrefix(scanner.Text(), "//")) + line = strings.TrimPrefix(line, " ") + if line != "" { + cleanedLines = append(cleanedLines, line) + } + } + + if err := scanner.Err(); err != nil { + return "", fmt.Errorf("failed to clean section: %v", err) + } + + return strings.Join(cleanedLines, "\n"), nil +} + //////////////////// Auto-Name Generator //////////////////// // generateCodeBlockName generates a name for a given code block based on its content. From 15770341009f96815b3b0dea5bda512864d53c1d Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Mon, 5 Aug 2024 17:24:15 +0900 Subject: [PATCH 33/37] update: compareResults, ExecuteBlock --- gnovm/pkg/doctest/exec.go | 24 +++++++++++-- gnovm/pkg/doctest/exec_test.go | 65 ++++++++++------------------------ gnovm/pkg/doctest/parser.go | 4 +-- 3 files changed, 41 insertions(+), 52 deletions(-) diff --git a/gnovm/pkg/doctest/exec.go b/gnovm/pkg/doctest/exec.go index 1cdb2e8105a..a0a38619499 100644 --- a/gnovm/pkg/doctest/exec.go +++ b/gnovm/pkg/doctest/exec.go @@ -75,12 +75,20 @@ func ExecuteCodeBlock(c codeBlock, stdlibDir string) (string, error) { hashKey := hashCodeBlock(c) + // get the result from the cache if it exists if result, found := cache.get(hashKey); found { - result, err := compareResults(result, c.expectedOutput, c.expectedError) + res := strings.TrimSpace(result) + + if c.expectedOutput == "" && c.expectedError == "" { + return fmt.Sprintf("%s (cached)", res), nil + } + + res, err := compareResults(res, c.expectedOutput, c.expectedError) if err != nil { return "", err } - return fmt.Sprintf("%s (cached)", result), nil + + return fmt.Sprintf("%s (cached)", res), nil } src, err := analyzeAndModifyCode(c.content) @@ -135,6 +143,13 @@ func ExecuteCodeBlock(c codeBlock, stdlibDir string) (string, error) { cache.set(hashKey, res) + // If there is no expected output or error, It is considered + // a simple code execution and the result is returned as is. + if c.expectedOutput == "" && c.expectedError == "" { + return res, nil + } + + // Otherwise, compare the actual output with the expected output or error. return compareResults(res, c.expectedOutput, c.expectedError) } @@ -147,7 +162,10 @@ func compareResults(actual, expectedOutput, expectedError string) (string, error } if expected == "" { - return actual, nil + if actual != "" { + return "", fmt.Errorf("expected no output, but got:\n%s", actual) + } + return "", nil } if strings.HasPrefix(expected, "regex:") { diff --git a/gnovm/pkg/doctest/exec_test.go b/gnovm/pkg/doctest/exec_test.go index 9fc5b78eb93..675c941083e 100644 --- a/gnovm/pkg/doctest/exec_test.go +++ b/gnovm/pkg/doctest/exec_test.go @@ -8,50 +8,6 @@ import ( "time" ) -func TestExecuteCodeBlockWithCache(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - codeBlock codeBlock - expect string - }{ - { - name: "import go stdlib package", - codeBlock: codeBlock{ - content: ` -package main - -func main() { - println("Hello, World") -}`, - lang: "gno", - }, - expect: "Hello, World\n (cached)\n", - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - stdlibDir := GetStdlibsDir() - _, err := ExecuteCodeBlock(tt.codeBlock, stdlibDir) - if err != nil { - t.Errorf("%s returned an error: %v", tt.name, err) - } - - cachedRes, err := ExecuteCodeBlock(tt.codeBlock, stdlibDir) - if err != nil { - t.Errorf("%s returned an error: %v", tt.name, err) - } - if cachedRes == tt.expect { - t.Errorf("%s = %v, want %v", tt.name, cachedRes, tt.expect) - } - }) - } -} - func TestHashCodeBlock(t *testing.T) { t.Parallel() codeBlock1 := codeBlock{ @@ -345,7 +301,7 @@ func TestCompareResults(t *testing.T) { name: "Empty expected", actual: "Hello, World!", expectedOutput: "", - expectedError: "", + wantErr: true, }, } @@ -389,7 +345,7 @@ func main() { More text `, pattern: "test1", - expectedResult: []string{"\n=== test1 ===\n\nHello, World!\n"}, + expectedResult: []string{"\n=== test1 ===\n\nHello, World!\n\n"}, expectError: false, }, { @@ -409,7 +365,7 @@ func main() { ` + "```" + ` `, pattern: "test*", - expectedResult: []string{"\n=== test1 ===\n\nFirst\n", "\n=== test2 ===\n\nSecond\n"}, + expectedResult: []string{"\n=== test1 ===\n\nFirst\n\n", "\n=== test2 ===\n\nSecond\n\n"}, expectError: false, }, { @@ -439,6 +395,21 @@ func main() { pattern: "error_test", expectError: true, }, + { + name: "expected output is nothing but actual output is something", + content: ` +` + "```go" + ` +// @test: foo +func main() { + println("This is an unexpected output") +} + +// Output: +` + "```" + ` +`, + pattern: "foo", + expectedResult: []string{"\n=== foo ===\n\nThis is an unexpected output\n\n"}, + }, } for _, tc := range testCases { diff --git a/gnovm/pkg/doctest/parser.go b/gnovm/pkg/doctest/parser.go index fe13efaae86..8707dea5e4d 100644 --- a/gnovm/pkg/doctest/parser.go +++ b/gnovm/pkg/doctest/parser.go @@ -141,7 +141,7 @@ func cleanSection(section string) (string, error) { } if err := scanner.Err(); err != nil { - return "", fmt.Errorf("failed to clean section: %v", err) + return "", fmt.Errorf("failed to clean section: %w", err) } return strings.Join(cleanedLines, "\n"), nil @@ -237,7 +237,7 @@ func containsCalculation(fn *ast.FuncDecl) bool { // of a Go file. It returns a slice of strings representing the imported // package names or the last part of the import path if no alias is used. func extractImports(f *ast.File) []string { - var imports []string + imports := make([]string, 0) for _, imp := range f.Imports { if imp.Name != nil { imports = append(imports, imp.Name.Name) From 2d64669b6bc052cbb8e16ca85c4c36a0cafc899d Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Mon, 5 Aug 2024 17:39:43 +0900 Subject: [PATCH 34/37] remove unnecessary lock --- gnovm/pkg/doctest/exec.go | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/gnovm/pkg/doctest/exec.go b/gnovm/pkg/doctest/exec.go index a0a38619499..07cd4eeceab 100644 --- a/gnovm/pkg/doctest/exec.go +++ b/gnovm/pkg/doctest/exec.go @@ -201,17 +201,16 @@ func ExecuteMatchingCodeBlock(ctx context.Context, content string, pattern strin var results []string for _, block := range codeBlocks { + if err := ctx.Err(); err != nil { + return nil, err + } + if matchPattern(block.name, pattern) { - select { - case <-ctx.Done(): - return nil, ctx.Err() - default: - result, err := ExecuteCodeBlock(block, GetStdlibsDir()) - if err != nil { - return nil, fmt.Errorf("failed to execute code block %s: %w", block.name, err) - } - results = append(results, fmt.Sprintf("\n=== %s ===\n\n%s\n", block.name, result)) + result, err := ExecuteCodeBlock(block, GetStdlibsDir()) + if err != nil { + return nil, fmt.Errorf("failed to execute code block %s: %w", block.name, err) } + results = append(results, fmt.Sprintf("\n=== %s ===\n\n%s\n", block.name, result)) } } From 317fd41c20cf0837eb618d382cd64bd1a80bf7ce Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Mon, 5 Aug 2024 19:03:59 +0900 Subject: [PATCH 35/37] fix: handle auto import for conflict names --- gnovm/pkg/doctest/analyzer.go | 85 +++++++++++++++----------- gnovm/pkg/doctest/analyzer_test.go | 98 ++++++++++++++++++++++++++++++ gnovm/pkg/doctest/cache.go | 6 -- gnovm/pkg/doctest/exec.go | 12 +--- 4 files changed, 150 insertions(+), 51 deletions(-) diff --git a/gnovm/pkg/doctest/analyzer.go b/gnovm/pkg/doctest/analyzer.go index 21207a298c7..ba7a942da1a 100644 --- a/gnovm/pkg/doctest/analyzer.go +++ b/gnovm/pkg/doctest/analyzer.go @@ -9,7 +9,6 @@ import ( "go/token" "sort" "strconv" - "strings" ) // supported stdlib packages in gno. @@ -37,6 +36,8 @@ var stdLibPackages = map[string]bool{ "unicode/utf16": true, "unicode/utf8": true, + "fmt": true, // for testing purposes + // partially supported packages "crypto/cipher": true, "crypto/ed25519": true, @@ -90,53 +91,69 @@ func hasMainFunction(node *ast.File) bool { return false } -// detectUsedPackages inspects the AST and returns a map of used stdlib packages. func detectUsedPackages(node *ast.File) map[string]bool { usedPackages := make(map[string]bool) - remainingPackages := make(map[string]bool) - for pkg := range stdLibPackages { - remainingPackages[pkg] = true - } + localIdentifiers := make(map[string]bool) + // 1st Pass: Collect local identifiers ast.Inspect(node, func(n ast.Node) bool { - if len(remainingPackages) == 0 { - return false - } - - selectorExpr, ok := n.(*ast.SelectorExpr) - if !ok { - return true - } - - ident, ok := selectorExpr.X.(*ast.Ident) - if !ok { - return true - } - - if remainingPackages[ident.Name] { - usedPackages[ident.Name] = true - delete(remainingPackages, ident.Name) - return false + switch x := n.(type) { + case *ast.FuncDecl: + localIdentifiers[x.Name.Name] = true + case *ast.GenDecl: + for _, spec := range x.Specs { + switch s := spec.(type) { + case *ast.ValueSpec: + for _, name := range s.Names { + localIdentifiers[name.Name] = true + } + case *ast.TypeSpec: + localIdentifiers[s.Name.Name] = true + } + } + case *ast.AssignStmt: + for _, expr := range x.Lhs { + if ident, ok := expr.(*ast.Ident); ok { + localIdentifiers[ident.Name] = true + } + } } + return true + }) - for fullPkg := range stdLibPackages { - if isMatchingSubpackage(fullPkg, ident.Name, selectorExpr.Sel.Name) { - usedPackages[fullPkg] = true - delete(remainingPackages, fullPkg) - return false + // 2nd Pass: Detect package usage + ast.Inspect(node, func(n ast.Node) bool { + switch x := n.(type) { + case *ast.SelectorExpr: + if ident, ok := x.X.(*ast.Ident); ok { + checkAndAddPackage(ident.Name, x.Sel.Name, usedPackages) + } + case *ast.CallExpr: + if selector, ok := x.Fun.(*ast.SelectorExpr); ok { + if ident, ok := selector.X.(*ast.Ident); ok { + checkAndAddPackage(ident.Name, selector.Sel.Name, usedPackages) + } + } + case *ast.Ident: + if stdLibPackages[x.Name] && !localIdentifiers[x.Name] { + usedPackages[x.Name] = true } } return true }) + return usedPackages } -func isMatchingSubpackage(fullPkg, prefix, suffix string) bool { - if !strings.HasPrefix(fullPkg, prefix+"/") { - return false +func checkAndAddPackage(pkg, sel string, usedPackages map[string]bool) { + if stdLibPackages[pkg] { + usedPackages[pkg] = true + } else { + fullPkg := pkg + "/" + sel + if stdLibPackages[fullPkg] { + usedPackages[fullPkg] = true + } } - parts := strings.SplitN(fullPkg, "/", 2) - return len(parts) == 2 && parts[1] == suffix } // updateImports modifies the AST to include all necessary import statements. diff --git a/gnovm/pkg/doctest/analyzer_test.go b/gnovm/pkg/doctest/analyzer_test.go index f2128d2b558..07950543976 100644 --- a/gnovm/pkg/doctest/analyzer_test.go +++ b/gnovm/pkg/doctest/analyzer_test.go @@ -88,3 +88,101 @@ func main() { }) } } + +func TestAnalyzeAndModifyCodeWithConflictingNames(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + expected string + }{ + { + name: "local identifier with same name as stdlib package", + input: `package main + +func main() { + math := 42 + println(math) +}`, + expected: `package main + +func main() { + math := 42 + println(math) +} +`, + }, + { + name: "local function with same name as stdlib package", + input: ` +package main + +func strings() string { + return "local strings function" +} + +func main() { + println(strings()) +}`, + expected: `package main + +func strings() string { + return "local strings function" +} + +func main() { + println(strings()) +} +`, + }, + { + name: "mixed use of local and stdlib identifiers", + input: `package main + +import ( + "fmt" +) + +func strings() string { + return "local strings function" +} + +func main() { + strings := strings() + fmt.Println(strings) + fmt.Println(strings.ToUpper("hello")) +}`, + expected: `package main + +import ( + "fmt" + "strings" +) + +func strings() string { + return "local strings function" +} + +func main() { + strings := strings() + fmt.Println(strings) + fmt.Println(strings.ToUpper("hello")) +} +`, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + modifiedCode, err := analyzeAndModifyCode(tt.input) + if err != nil { + t.Fatalf("AnalyzeAndModifyCode(%s) returned error: %v", tt.name, err) + } + if modifiedCode != tt.expected { + t.Errorf("AnalyzeAndModifyCode(%s) = %v, want %v", tt.name, modifiedCode, tt.expected) + } + }) + } +} diff --git a/gnovm/pkg/doctest/cache.go b/gnovm/pkg/doctest/cache.go index 9bf9267d49e..7d36228b296 100644 --- a/gnovm/pkg/doctest/cache.go +++ b/gnovm/pkg/doctest/cache.go @@ -2,7 +2,6 @@ package doctest import ( "container/list" - "sync" ) const maxCacheSize = 25 @@ -16,7 +15,6 @@ type lruCache struct { capacity int items map[string]*list.Element order *list.List - mutex sync.RWMutex } func newCache(capacity int) *lruCache { @@ -28,8 +26,6 @@ func newCache(capacity int) *lruCache { } func (c *lruCache) get(key string) (string, bool) { - c.mutex.RLock() - defer c.mutex.RUnlock() if elem, ok := c.items[key]; ok { c.order.MoveToFront(elem) return elem.Value.(cacheItem).value, true @@ -38,8 +34,6 @@ func (c *lruCache) get(key string) (string, bool) { } func (c *lruCache) set(key, value string) { - c.mutex.Lock() - defer c.mutex.Unlock() if elem, ok := c.items[key]; ok { c.order.MoveToFront(elem) elem.Value = cacheItem{key, value} diff --git a/gnovm/pkg/doctest/exec.go b/gnovm/pkg/doctest/exec.go index 07cd4eeceab..6bdea52adbc 100644 --- a/gnovm/pkg/doctest/exec.go +++ b/gnovm/pkg/doctest/exec.go @@ -9,7 +9,6 @@ import ( "regexp" "runtime" "strings" - "sync" "github.com/gnolang/gno/gno.land/pkg/sdk/vm" bft "github.com/gnolang/gno/tm2/pkg/bft/types" @@ -217,25 +216,16 @@ func ExecuteMatchingCodeBlock(ctx context.Context, content string, pattern strin return results, nil } -var ( - regexCache = make(map[string]*regexp.Regexp) - regexCacheMu sync.RWMutex -) +var regexCache = make(map[string]*regexp.Regexp) // getCompiledRegex retrieves or compiles a regex pattern. // it uses a cache to store compiled regex patterns for reuse. func getCompiledRegex(pattern string) (*regexp.Regexp, error) { - regexCacheMu.RLock() re, exists := regexCache[pattern] - regexCacheMu.RUnlock() - if exists { return re, nil } - regexCacheMu.Lock() - defer regexCacheMu.Unlock() - // double-check in case another goroutine has compiled the regex if re, exists = regexCache[pattern]; exists { return re, nil From ae25209162c887ba4075db114ac67419ff42c932 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Mon, 5 Aug 2024 21:17:24 +0900 Subject: [PATCH 36/37] readme --- gnovm/{cmd/gno/testdata/doctest/test.md => pkg/doctest/README.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename gnovm/{cmd/gno/testdata/doctest/test.md => pkg/doctest/README.md} (100%) diff --git a/gnovm/cmd/gno/testdata/doctest/test.md b/gnovm/pkg/doctest/README.md similarity index 100% rename from gnovm/cmd/gno/testdata/doctest/test.md rename to gnovm/pkg/doctest/README.md From 45265f28826897c41222a2730f8459c9fb3ec9c8 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Tue, 6 Aug 2024 11:54:52 +0900 Subject: [PATCH 37/37] remove auto import --- gnovm/pkg/doctest/README.md | 78 +++-------- gnovm/pkg/doctest/analyzer.go | 218 ----------------------------- gnovm/pkg/doctest/analyzer_test.go | 188 ------------------------- gnovm/pkg/doctest/exec.go | 7 +- gnovm/pkg/doctest/exec_test.go | 10 ++ 5 files changed, 28 insertions(+), 473 deletions(-) delete mode 100644 gnovm/pkg/doctest/analyzer.go delete mode 100644 gnovm/pkg/doctest/analyzer_test.go diff --git a/gnovm/pkg/doctest/README.md b/gnovm/pkg/doctest/README.md index 60f72287f40..901f2d19ce4 100644 --- a/gnovm/pkg/doctest/README.md +++ b/gnovm/pkg/doctest/README.md @@ -48,84 +48,40 @@ func main() { Running this code will output "Hello, World!". -## 2. Using Standard Library Packages +### 3. Execution Options -Doctest supports automatic import and usage of standard library packages. - -If run this code, doctest will automatically import the "std" package and execute the code. +Doctest supports special execution options: +Ignore Option +Use the ignore tag to skip execution of a code block: -```go -// @test: omit-package-declaration -func main() { - addr := std.GetOrigCaller() - println(addr) -} -``` +**Ignore Option** -The code above outputs the same result as the code below. +Use the ignore tag to skip execution of a code block: -```go -// @test: auto-import-package +```go,ignore +// @ignore package main -import "std" - func main() { - addr := std.GetOrigCaller() - println(addr) + println("This won't be executed") } ``` -## 3. Automatic Package Import +## Conclusion -One of the most powerful features of Gno Doctest is its ability to handle package declarations and imports automatically. +Gno Doctest simplifies the process of executing and testing Gno code snippets. ```go -func main() { - println(math.Pi) - println(strings.ToUpper("Hello, World")) -} -``` - -In this code, the math and strings packages are not explicitly imported, but Doctest automatically recognizes and imports the necessary packages. - -## 4. Omitting Package Declaration +// @test: slice +package main -Doctest can even handle cases where the `package` declaration is omitted. +type ints []int -```go -// @test: omit-top-level-package-declaration func main() { - s := strings.ToUpper("Hello, World") - println(s) + a := ints{1,2,3} + println(a) } // Output: -// HELLO, WORLD +// (slice[(1 int),(2 int),(3 int)] gno.land/r/g14ch5q26mhx3jk5cxl88t278nper264ces4m8nt/run.ints) ``` - -This code runs normally without package declaration or import statements. -Using Gno Doctest makes code execution and testing much more convenient. - -You can quickly run various Gno code snippets and check the results without complex setups. - -### 7. Execution Options - -Doctest supports special execution options: -Ignore Option -Use the ignore tag to skip execution of a code block: - -**Ignore Option** - -Use the ignore tag to skip execution of a code block: - -```go,ignore -// @ignore -func main() { - println("This won't be executed") -} -``` - -## Conclusion - -Gno Doctest simplifies the process of executing and testing Gno code snippets. diff --git a/gnovm/pkg/doctest/analyzer.go b/gnovm/pkg/doctest/analyzer.go deleted file mode 100644 index ba7a942da1a..00000000000 --- a/gnovm/pkg/doctest/analyzer.go +++ /dev/null @@ -1,218 +0,0 @@ -package doctest - -import ( - "bytes" - "fmt" - "go/ast" - "go/parser" - "go/printer" - "go/token" - "sort" - "strconv" -) - -// supported stdlib packages in gno. -// ref: go-gno-compatibility.md -var stdLibPackages = map[string]bool{ - "bufio": true, - "builtin": true, - "bytes": true, - "encoding": true, - "encoding/base64": true, - "encoding/hex": true, - "hash": true, - "hash/adler32": true, - "io": true, - "math": true, - "math/bits": true, - "net/url": true, - "path": true, - "regexp": true, - "regexp/syntax": true, - "std": true, - "strings": true, - "time": true, - "unicode": true, - "unicode/utf16": true, - "unicode/utf8": true, - - "fmt": true, // for testing purposes - - // partially supported packages - "crypto/cipher": true, - "crypto/ed25519": true, - "crypto/sha256": true, - "encoding/binary": true, - "errors": true, - "sort": true, - "strconv": true, - "testing": true, -} - -// analyzeAndModifyCode analyzes the given code block, adds package declaration if missing, -// ensures a main function exists, and updates imports. It returns the modified code as a string. -func analyzeAndModifyCode(code string) (string, error) { - fset := token.NewFileSet() - node, err := parser.ParseFile(fset, "", code, parser.AllErrors) - if err != nil { - // Prepend package main to the code and try to parse again. - node, err = parser.ParseFile(fset, "", "package main\n"+code, parser.ParseComments) - if err != nil { - return "", fmt.Errorf("failed to parse code: %w", err) - } - } - - if node.Name == nil { - node.Name = ast.NewIdent("main") - } - - if !hasMainFunction(node) { - return "", fmt.Errorf("main function is missing") - } - - updateImports(node) - - src, err := codePrettier(fset, node) - if err != nil { - return "", err - } - - return src, nil -} - -// hasMainFunction checks if a main function exists in the AST. -// It returns an error if the main function is missing. -func hasMainFunction(node *ast.File) bool { - for _, decl := range node.Decls { - if fn, isFn := decl.(*ast.FuncDecl); isFn && fn.Name.Name == "main" { - return true - } - } - return false -} - -func detectUsedPackages(node *ast.File) map[string]bool { - usedPackages := make(map[string]bool) - localIdentifiers := make(map[string]bool) - - // 1st Pass: Collect local identifiers - ast.Inspect(node, func(n ast.Node) bool { - switch x := n.(type) { - case *ast.FuncDecl: - localIdentifiers[x.Name.Name] = true - case *ast.GenDecl: - for _, spec := range x.Specs { - switch s := spec.(type) { - case *ast.ValueSpec: - for _, name := range s.Names { - localIdentifiers[name.Name] = true - } - case *ast.TypeSpec: - localIdentifiers[s.Name.Name] = true - } - } - case *ast.AssignStmt: - for _, expr := range x.Lhs { - if ident, ok := expr.(*ast.Ident); ok { - localIdentifiers[ident.Name] = true - } - } - } - return true - }) - - // 2nd Pass: Detect package usage - ast.Inspect(node, func(n ast.Node) bool { - switch x := n.(type) { - case *ast.SelectorExpr: - if ident, ok := x.X.(*ast.Ident); ok { - checkAndAddPackage(ident.Name, x.Sel.Name, usedPackages) - } - case *ast.CallExpr: - if selector, ok := x.Fun.(*ast.SelectorExpr); ok { - if ident, ok := selector.X.(*ast.Ident); ok { - checkAndAddPackage(ident.Name, selector.Sel.Name, usedPackages) - } - } - case *ast.Ident: - if stdLibPackages[x.Name] && !localIdentifiers[x.Name] { - usedPackages[x.Name] = true - } - } - return true - }) - - return usedPackages -} - -func checkAndAddPackage(pkg, sel string, usedPackages map[string]bool) { - if stdLibPackages[pkg] { - usedPackages[pkg] = true - } else { - fullPkg := pkg + "/" + sel - if stdLibPackages[fullPkg] { - usedPackages[fullPkg] = true - } - } -} - -// updateImports modifies the AST to include all necessary import statements. -// based on the packages used in the code and existing imports. -func updateImports(node *ast.File) { - usedPackages := detectUsedPackages(node) - - // Remove existing imports - node.Decls = removeImportDecls(node.Decls) - - // Add new imports only for used packages - if len(usedPackages) > 0 { - importSpecs := createImportSpecs(usedPackages) - importDecl := &ast.GenDecl{ - Tok: token.IMPORT, - Lparen: token.Pos(1), - Specs: importSpecs, - } - node.Decls = append([]ast.Decl{importDecl}, node.Decls...) - } -} - -// createImportSpecs generates a slice of import specifications from a map of importable package paths. -// It sorts the paths alphabetically before creating the import specs. -func createImportSpecs(imports map[string]bool) []ast.Spec { - paths := make([]string, 0, len(imports)) - for path := range imports { - paths = append(paths, path) - } - - sort.Strings(paths) - - specs := make([]ast.Spec, 0, len(imports)) - for _, path := range paths { - specs = append(specs, &ast.ImportSpec{ - Path: &ast.BasicLit{ - Kind: token.STRING, - Value: strconv.Quote(path), - }, - }) - } - return specs -} - -// removeImportDecls filters out import declarations from a slice of declarations. -func removeImportDecls(decls []ast.Decl) []ast.Decl { - result := make([]ast.Decl, 0, len(decls)) - for _, decl := range decls { - if genDecl, ok := decl.(*ast.GenDecl); !ok || genDecl.Tok != token.IMPORT { - result = append(result, decl) - } - } - return result -} - -func codePrettier(fset *token.FileSet, node *ast.File) (string, error) { - var buf bytes.Buffer - if err := printer.Fprint(&buf, fset, node); err != nil { - return "", fmt.Errorf("failed to print code: %w", err) - } - return buf.String(), nil -} diff --git a/gnovm/pkg/doctest/analyzer_test.go b/gnovm/pkg/doctest/analyzer_test.go deleted file mode 100644 index 07950543976..00000000000 --- a/gnovm/pkg/doctest/analyzer_test.go +++ /dev/null @@ -1,188 +0,0 @@ -package doctest - -import ( - "testing" -) - -func TestAnalyzeAndModifyCode(t *testing.T) { - t.Parallel() - tests := []struct { - name string - input string - expected string - }{ - { - name: "simple hello world without package and import", - input: ` -func main() { - println("Hello, World") -}`, - expected: `package main - -func main() { - println("Hello, World") -} -`, - }, - { - name: "main with address without package", - input: ` -import ( - "std" -) - -func main() { - addr := std.GetOrigCaller() - println(addr) -}`, - expected: `package main - -import ( - "std" -) - -func main() { - addr := std.GetOrigCaller() - println(addr) -} -`, - }, - { - name: "multiple imports without package and import statement", - input: ` -import ( - "math" - "strings" -) - -func main() { - println(math.Pi) - println(strings.ToUpper("Hello, World")) -}`, - expected: `package main - -import ( - "math" - "strings" -) - -func main() { - println(math.Pi) - println(strings.ToUpper("Hello, World")) -} -`, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - modifiedCode, err := analyzeAndModifyCode(tt.input) - if err != nil { - t.Fatalf("AnalyzeAndModifyCode(%s) returned error: %v", tt.name, err) - } - if modifiedCode != tt.expected { - t.Errorf("AnalyzeAndModifyCode(%s) = %v, want %v", tt.name, modifiedCode, tt.expected) - } - }) - } -} - -func TestAnalyzeAndModifyCodeWithConflictingNames(t *testing.T) { - t.Parallel() - tests := []struct { - name string - input string - expected string - }{ - { - name: "local identifier with same name as stdlib package", - input: `package main - -func main() { - math := 42 - println(math) -}`, - expected: `package main - -func main() { - math := 42 - println(math) -} -`, - }, - { - name: "local function with same name as stdlib package", - input: ` -package main - -func strings() string { - return "local strings function" -} - -func main() { - println(strings()) -}`, - expected: `package main - -func strings() string { - return "local strings function" -} - -func main() { - println(strings()) -} -`, - }, - { - name: "mixed use of local and stdlib identifiers", - input: `package main - -import ( - "fmt" -) - -func strings() string { - return "local strings function" -} - -func main() { - strings := strings() - fmt.Println(strings) - fmt.Println(strings.ToUpper("hello")) -}`, - expected: `package main - -import ( - "fmt" - "strings" -) - -func strings() string { - return "local strings function" -} - -func main() { - strings := strings() - fmt.Println(strings) - fmt.Println(strings.ToUpper("hello")) -} -`, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - modifiedCode, err := analyzeAndModifyCode(tt.input) - if err != nil { - t.Fatalf("AnalyzeAndModifyCode(%s) returned error: %v", tt.name, err) - } - if modifiedCode != tt.expected { - t.Errorf("AnalyzeAndModifyCode(%s) = %v, want %v", tt.name, modifiedCode, tt.expected) - } - }) - } -} diff --git a/gnovm/pkg/doctest/exec.go b/gnovm/pkg/doctest/exec.go index 6bdea52adbc..3b0638d52ed 100644 --- a/gnovm/pkg/doctest/exec.go +++ b/gnovm/pkg/doctest/exec.go @@ -90,11 +90,6 @@ func ExecuteCodeBlock(c codeBlock, stdlibDir string) (string, error) { return fmt.Sprintf("%s (cached)", res), nil } - src, err := analyzeAndModifyCode(c.content) - if err != nil { - return "", err - } - baseKey := store.NewStoreKey("baseKey") iavlKey := store.NewStoreKey("iavlKey") @@ -116,7 +111,7 @@ func ExecuteCodeBlock(c codeBlock, stdlibDir string) (string, error) { mcw.MultiWrite() files := []*std.MemFile{ - {Name: fmt.Sprintf("%d.%s", c.index, lang), Body: src}, + {Name: fmt.Sprintf("%d.%s", c.index, lang), Body: c.content}, } addr := crypto.AddressFromPreimage([]byte("addr1")) diff --git a/gnovm/pkg/doctest/exec_test.go b/gnovm/pkg/doctest/exec_test.go index 675c941083e..5aeebd452e6 100644 --- a/gnovm/pkg/doctest/exec_test.go +++ b/gnovm/pkg/doctest/exec_test.go @@ -338,6 +338,8 @@ func TestExecuteMatchingCodeBlock(t *testing.T) { Some text here ` + "```go" + ` // @test: test1 +package main + func main() { println("Hello, World!") } @@ -353,12 +355,16 @@ More text content: ` ` + "```go" + ` // @test: test1 +package main + func main() { println("First") } ` + "```" + ` ` + "```go" + ` // @test: test2 +package main + func main() { println("Second") } @@ -387,6 +393,8 @@ func main() { content: ` ` + "```go" + ` // @test: error_test +package main + func main() { panic("This should cause an error") } @@ -400,6 +408,8 @@ func main() { content: ` ` + "```go" + ` // @test: foo +package main + func main() { println("This is an unexpected output") }