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
208 changes: 179 additions & 29 deletions gnovm/cmd/gno/repl.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,43 @@ import (
"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

importExample = "import \"gno.land/p/demo/avl\""
funcExample = "func a() string { return \"a\" }"
printExample = "println(a())"
srcCommand = "/src"
editorCommand = "/editor"
resetCommand = "/reset"
exitCommand = "/exit"
clearCommand = "/clear"
helpCommand = "/help"
gnoREPL = "gno> "
inEditMode = "... "

helpText = `// Usage:
// gno> %-35s // import the p/demo/avl package
// gno> %-35s // declare a new function named a
// gno> %-35s // print current generated source
// gno> %-35s // enter in multi-line mode, end with ';'
// gno> %-35s // clear the terminal screen
// gno> %-35s // remove all previously inserted code
// gno> %-35s // print the result of calling a()
// gno> %-35s // alternative to <Ctrl-D>

`
)

type replCfg struct {
rootDir string
initialCommand string
Expand Down Expand Up @@ -70,15 +100,7 @@ func execRepl(cfg *replCfg, args []string) error {
}

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()
}

return runRepl(cfg)
Expand All @@ -91,68 +113,196 @@ func runRepl(cfg *replCfg) error {
handleInput(r, cfg.initialCommand)
}

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

var (
inEdit bool
prev string
indentLevel int
)

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.


if prev != "" {
line = prev + "\n" + line
prev = ""
}

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

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.
// 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)
}

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
}

return line, false
}

func updateIndentLevel(line string, indentLevel int) int {
openCount, closeCount := countBrackets(line)
increaseIndent := shouldIncreaseIndent(line)
indentLevel += openCount - closeCount

if indentLevel < 0 {
indentLevel = 0
}

if increaseIndent {
indentLevel++
}

return indentLevel
}

func countBrackets(line string) (int, int) {
openCount, closeCount := 0, 0
notJoon marked this conversation as resolved.
Show resolved Hide resolved
inString, inComment, inSingleLineComment := false, false, false
var stringChar rune

for i, char := range line {
if !inString && !inComment && !inSingleLineComment {
switch char {
case '{', '(', '[':
openCount++
case '}', ')', ']':
closeCount++
case '"', '\'':
notJoon marked this conversation as resolved.
Show resolved Hide resolved
inString = true
stringChar = char
case '/':
if i < len(line)-1 {
if line[i+1] == '/' {
inSingleLineComment = true
} else if line[i+1] == '*' {
inComment = true
}
}
}
} else if inString && char == stringChar {
inString = false
} else if inComment && i < len(line)-1 && char == '*' && line[i+1] == '/' {
inComment = false
}
}

return openCount, closeCount
}

func shouldIncreaseIndent(line string) bool {
notJoon marked this conversation as resolved.
Show resolved Hide resolved
openIndex := strings.IndexAny(line, "{([")
if openIndex != -1 && openIndex < len(line)-1 && line[openIndex+1] == '\n' {
return true
}
return false
}

func printPrompt(indentLevel int, prev string) {
indent := strings.Repeat(" ", indentLevel*indentSize)
if prev == "" {
fmt.Fprintf(os.Stdout, "%s%s", gnoREPL, indent)
} else {
fmt.Fprintf(os.Stdout, "%s%s", inEditMode, indent)
}
}

// 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:
r.Reset()
case "/src":
case srcCommand:
fmt.Fprintln(os.Stdout, r.Src())
case "/exit":
case clearCommand:
clearScreen(&RealCommandExecutor{}, RealOSGetter{})
case exitCommand:
os.Exit(0)
case helpCommand:
printHelp()
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()
}

type OsGetter interface {
Get() string
}

type RealOSGetter struct{}

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

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.Printf(
helpText, importExample, funcExample,
srcCommand, editorCommand, clearCommand,
resetCommand, exitCommand, printExample,
notJoon marked this conversation as resolved.
Show resolved Hide resolved
)
}
Loading
Loading