Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(gnovm): Add Basic Code Indentation in REPL #1596

Open
wants to merge 16 commits into
base: master
Choose a base branch
from
173 changes: 144 additions & 29 deletions gnovm/cmd/gno/repl.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,28 @@
"fmt"
"go/scanner"
"os"
"os/exec"
"runtime"
"strings"

"github.com/gnolang/gno/gnovm/pkg/gnoenv"
"github.com/gnolang/gno/gnovm/pkg/repl"
"github.com/gnolang/gno/tm2/pkg/commands"
)

const indentSize = 4

const (
srcCommand = "/src"
editorCommand = "/editor"
resetCommand = "/reset"
exitCommand = "/exit"
clearCommand = "/clear"
helpCommand = "/help"
gnoREPL = "gno> "
inEditMode = "... "
)

type replCfg struct {
verbose bool
rootDir string
Expand Down Expand Up @@ -86,15 +101,7 @@
}

if !cfg.skipUsage {
fmt.Fprint(os.Stderr, `// Usage:
// gno> import "gno.land/p/demo/avl" // import the p/demo/avl package
// gno> func a() string { return "a" } // declare a new function named a
// gno> /src // print current generated source
// gno> /editor // enter in multi-line mode, end with ';'
// gno> /reset // remove all previously inserted code
// gno> println(a()) // print the result of calling a()
// gno> /exit // alternative to <Ctrl-D>
`)
printHelp()

Check warning on line 104 in gnovm/cmd/gno/repl.go

View check run for this annotation

Codecov / codecov/patch

gnovm/cmd/gno/repl.go#L104

Added line #L104 was not covered by tests
}

return runRepl(cfg)
Expand All @@ -107,68 +114,176 @@
handleInput(r, cfg.initialCommand)
}

fmt.Fprint(os.Stdout, "gno> ")
fmt.Fprint(os.Stdout, gnoREPL)

var (
inEdit bool
prev string
indentLevel int
)

Check warning on line 123 in gnovm/cmd/gno/repl.go

View check run for this annotation

Codecov / codecov/patch

gnovm/cmd/gno/repl.go#L117-L123

Added lines #L117 - L123 were not covered by tests

inEdit := false
prev := ""
liner := bufio.NewScanner(os.Stdin)

for liner.Scan() {
line := liner.Text()

if l := strings.TrimSpace(line); l == ";" {
line, inEdit = "", false
} else if l == "/editor" {
line, inEdit = "", true
fmt.Fprintln(os.Stdout, "// enter a single ';' to quit and commit")
}
trimmedLine := strings.TrimSpace(line)

indentLevel = updateIndentLevel(trimmedLine, indentLevel)
line, inEdit = handleEditor(line)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change breaks the editor mode. I'd recommend restoring it since it is only called from this one location and moving it to its own function doesn't have too much benefit. The issue can by entering editor mode with /editor and then doing some variable assignments. Notice that editor mode is exited after doing a single variable assignment when it should remain in editor mode until the semicolon is encountered.


Check warning on line 134 in gnovm/cmd/gno/repl.go

View check run for this annotation

Codecov / codecov/patch

gnovm/cmd/gno/repl.go#L130-L134

Added lines #L130 - L134 were not covered by tests
if prev != "" {
line = prev + "\n" + line
prev = ""
}

if inEdit {
fmt.Fprint(os.Stdout, "... ")
fmt.Fprint(os.Stdout, inEditMode)

Check warning on line 141 in gnovm/cmd/gno/repl.go

View check run for this annotation

Codecov / codecov/patch

gnovm/cmd/gno/repl.go#L141

Added line #L141 was not covered by tests
prev = line

Check warning on line 143 in gnovm/cmd/gno/repl.go

View check run for this annotation

Codecov / codecov/patch

gnovm/cmd/gno/repl.go#L143

Added line #L143 was not covered by tests
continue
}

if err := handleInput(r, line); err != nil {
var goScanError scanner.ErrorList
if errors.As(err, &goScanError) {
// We assune that a Go scanner error indicates an incomplete Go statement.
// We assume that a Go scanner error indicates an incomplete Go statement.

Check warning on line 150 in gnovm/cmd/gno/repl.go

View check run for this annotation

Codecov / codecov/patch

gnovm/cmd/gno/repl.go#L150

Added line #L150 was not covered by tests
// Append next line and retry.
prev = line
} else {
fmt.Fprintln(os.Stderr, err)
}
}

if prev == "" {
fmt.Fprint(os.Stdout, "gno> ")
} else {
fmt.Fprint(os.Stdout, "... ")
}
printPrompt(indentLevel, prev)

Check warning on line 158 in gnovm/cmd/gno/repl.go

View check run for this annotation

Codecov / codecov/patch

gnovm/cmd/gno/repl.go#L158

Added line #L158 was not covered by tests
}

return nil
}

func handleEditor(line string) (string, bool) {
if l := strings.TrimSpace(line); l == ";" {
return "", false
} else if l == editorCommand {
fmt.Fprintln(os.Stdout, "// enter a single ';' to quit and commit")

return "", true
}

Check warning on line 171 in gnovm/cmd/gno/repl.go

View check run for this annotation

Codecov / codecov/patch

gnovm/cmd/gno/repl.go#L164-L171

Added lines #L164 - L171 were not covered by tests

return line, false

Check warning on line 173 in gnovm/cmd/gno/repl.go

View check run for this annotation

Codecov / codecov/patch

gnovm/cmd/gno/repl.go#L173

Added line #L173 was not covered by tests
}

func updateIndentLevel(line string, indentLevel int) int {
openCount, closeCount := 0, 0
increaseIndent := false

for i, char := range line {
notJoon marked this conversation as resolved.
Show resolved Hide resolved
switch char {
case '{', '(', '[':
openCount++

if i < len(line)-1 && line[i+1] == '\n' {
increaseIndent = true
}

Check warning on line 187 in gnovm/cmd/gno/repl.go

View check run for this annotation

Codecov / codecov/patch

gnovm/cmd/gno/repl.go#L186-L187

Added lines #L186 - L187 were not covered by tests
case '}', ')', ']':
closeCount++
}
}

indentLevel += openCount - closeCount
if indentLevel < 0 {
indentLevel = 0
}

Check warning on line 196 in gnovm/cmd/gno/repl.go

View check run for this annotation

Codecov / codecov/patch

gnovm/cmd/gno/repl.go#L195-L196

Added lines #L195 - L196 were not covered by tests

if increaseIndent {
indentLevel++
}

Check warning on line 200 in gnovm/cmd/gno/repl.go

View check run for this annotation

Codecov / codecov/patch

gnovm/cmd/gno/repl.go#L199-L200

Added lines #L199 - L200 were not covered by tests

if strings.HasSuffix(line, ":") {
notJoon marked this conversation as resolved.
Show resolved Hide resolved
indentLevel++
}

return indentLevel
}

func printPrompt(indentLevel int, prev string) {
if prev == "" {
fmt.Fprintf(os.Stdout, "gno> %s", strings.Repeat(" ", indentLevel*indentSize))
notJoon marked this conversation as resolved.
Show resolved Hide resolved
} else {
fmt.Fprintf(os.Stdout, "... %s", strings.Repeat(" ", indentLevel*indentSize))
}

Check warning on line 214 in gnovm/cmd/gno/repl.go

View check run for this annotation

Codecov / codecov/patch

gnovm/cmd/gno/repl.go#L209-L214

Added lines #L209 - L214 were not covered by tests
}

// handleInput executes specific "/" commands, or evaluates input as Gno source code.
func handleInput(r *repl.Repl, input string) error {
switch strings.TrimSpace(input) {
case "/reset":
input = strings.TrimSpace(input)
switch input {
case resetCommand:

Check warning on line 221 in gnovm/cmd/gno/repl.go

View check run for this annotation

Codecov / codecov/patch

gnovm/cmd/gno/repl.go#L219-L221

Added lines #L219 - L221 were not covered by tests
r.Reset()
case "/src":
case srcCommand:

Check warning on line 223 in gnovm/cmd/gno/repl.go

View check run for this annotation

Codecov / codecov/patch

gnovm/cmd/gno/repl.go#L223

Added line #L223 was not covered by tests
fmt.Fprintln(os.Stdout, r.Src())
case "/exit":
case clearCommand:
clearScreen(&RealCommandExecutor{}, RealOSGetter{})
case exitCommand:

Check warning on line 227 in gnovm/cmd/gno/repl.go

View check run for this annotation

Codecov / codecov/patch

gnovm/cmd/gno/repl.go#L225-L227

Added lines #L225 - L227 were not covered by tests
os.Exit(0)
case helpCommand:
printHelp()

Check warning on line 230 in gnovm/cmd/gno/repl.go

View check run for this annotation

Codecov / codecov/patch

gnovm/cmd/gno/repl.go#L229-L230

Added lines #L229 - L230 were not covered by tests
case "":
// Avoid to increase the repl execution counter if no input.
default:
out, err := r.Process(input)
if err != nil {
return err
}

fmt.Fprintln(os.Stdout, out)
}

return nil
}

type CommandExecutor interface {
Execute(cmd *exec.Cmd) error
}

type RealCommandExecutor struct{}

func (e *RealCommandExecutor) Execute(cmd *exec.Cmd) error {
cmd.Stdout = os.Stdout
return cmd.Run()

Check warning on line 253 in gnovm/cmd/gno/repl.go

View check run for this annotation

Codecov / codecov/patch

gnovm/cmd/gno/repl.go#L251-L253

Added lines #L251 - L253 were not covered by tests
}

type OsGetter interface {
Get() string
}

type RealOSGetter struct{}

func (r RealOSGetter) Get() string {
return runtime.GOOS

Check warning on line 263 in gnovm/cmd/gno/repl.go

View check run for this annotation

Codecov / codecov/patch

gnovm/cmd/gno/repl.go#L262-L263

Added lines #L262 - L263 were not covered by tests
}

func clearScreen(executor CommandExecutor, osGetter OsGetter) {
var cmd *exec.Cmd

if osGetter.Get() == "windows" {
cmd = exec.Command("cmd", "/c", "cls")
} else {
cmd = exec.Command("clear")
}

executor.Execute(cmd)
}

func printHelp() {
fmt.Fprint(os.Stderr, `// Usage:
// gno> import "gno.land/p/demo/avl" // import the p/demo/avl package
// gno> func a() string { return "a" } // declare a new function named a
// gno> /src // print current generated source
notJoon marked this conversation as resolved.
Show resolved Hide resolved
// gno> /editor // enter in multi-line mode, end with ';'
// gno> /reset // remove all previously inserted code
notJoon marked this conversation as resolved.
Show resolved Hide resolved
// gno> println(a()) // print the result of calling a()
// gno> /exit // alternative to <Ctrl-D>

`)

Check warning on line 288 in gnovm/cmd/gno/repl.go

View check run for this annotation

Codecov / codecov/patch

gnovm/cmd/gno/repl.go#L278-L288

Added lines #L278 - L288 were not covered by tests
}
130 changes: 129 additions & 1 deletion gnovm/cmd/gno/repl_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package main

import "testing"
import (
"os/exec"
"reflect"
"testing"
)

func TestReplApp(t *testing.T) {
tc := []testMainCase{
Expand All @@ -11,3 +15,127 @@ func TestReplApp(t *testing.T) {
}
testMainCaseRun(t, tc)
}

func TestUpdateIndentLevel(t *testing.T) {
tests := []struct {
notJoon marked this conversation as resolved.
Show resolved Hide resolved
name string
line string
indentLevel int
notJoon marked this conversation as resolved.
Show resolved Hide resolved
want int
}{
{
name: "Test with no brackets",
line: "Hello, World!",
indentLevel: 0,
notJoon marked this conversation as resolved.
Show resolved Hide resolved
want: 0,
},
{
name: "Test with open brackets",
line: "func main() {",
indentLevel: 0,
want: 1,
},
{
name: "Test with closed brackets",
line: "}",
indentLevel: 1,
want: 0,
},
{
name: "Test with colon",
line: "case 'a':",
indentLevel: 0,
want: 1,
},
{
name: "Test with multiple open brackets",
line: "func main() { if true {",
indentLevel: 0,
want: 2,
},
{
name: "Test with multiple closed brackets",
line: "} }",
indentLevel: 2,
want: 0,
},
{
name: "Test with mixed brackets",
line: "} else {",
indentLevel: 1,
want: 1,
},
{
name: "Test with no change in indent level",
line: "fmt.Println(\"Hello, World!\")",
indentLevel: 1,
want: 1,
},
{
name: "Test with colon and open bracket",
line: "case 'a': {",
indentLevel: 0,
want: 1,
},
{
name: "Test with colon and closed bracket",
line: "case 'a': }",
indentLevel: 1,
want: 0,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := updateIndentLevel(tt.line, tt.indentLevel); got != tt.want {
t.Errorf("updateIndentLevel() = %v, want %v", got, tt.want)
}
})
}
}

type MockCommandExecutor struct {
ExecutedCommand *exec.Cmd
}

func (e *MockCommandExecutor) Execute(cmd *exec.Cmd) error {
e.ExecutedCommand = cmd
return nil
}

type MockOSGetter struct {
OS string
}

func (m MockOSGetter) Get() string {
return m.OS
}

func TestClearScreen(t *testing.T) {
tests := []struct {
name string
osGetter OsGetter
expected []string
}{
{"Windows", MockOSGetter{OS: "windows"}, []string{"cmd", "/c", "cls"}},
{"Other", MockOSGetter{OS: "linux"}, []string{"clear"}},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Set up the mock executor
executor := &MockCommandExecutor{}

// Call the function under test with the mock OS getter
clearScreen(executor, tt.osGetter)

// Check that the correct command was executed
if executor.ExecutedCommand == nil {
t.Fatal("Expected a command to be executed, but it was not")
}
if !reflect.DeepEqual(executor.ExecutedCommand.Args, tt.expected) {
t.Errorf("Expected command %v, but got %v", tt.expected, executor.ExecutedCommand.Args)
}
})
}
}
Loading