Repository: cerbos/cerbos
Pull request #859 (2022-04-22 15:37:37)

enhancement: Auto-detect end of input in REPL
Signed-off-by: Oğuzhan Durgun <oguzhandurgun95@gmail.com>

#### Description
This PR changes the behavior of the REPL in a way that instead of determining the end of the input only with the characters `\\`, it also uses the special characters (`{`, `}`, `[`, `]`, `(`, `)`, `"`, `"`).


codecov-commenter: # [Codecov](https://codecov.io/gh/cerbos/cerbos/pull/859?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cerbos) Report
> Merging [#859](https://codecov.io/gh/cerbos/cerbos/pull/859?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cerbos) (b47030a) into [main](https://codecov.io/gh/cerbos/cerbos/commit/558db5681ce0d943914033145dbb2078debdecde?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cerbos) (558db56) will **increase** coverage by `0.40%`.
> The diff coverage is `87.69%`.

[![Impacted file tree graph](https://codecov.io/gh/cerbos/cerbos/pull/859/graphs/tree.svg?width=650&height=150&src=pr&token=tGaxiUZUzL&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cerbos)](https://codecov.io/gh/cerbos/cerbos/pull/859?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cerbos)

```diff
@@            Coverage Diff             @@
##             main     #859      +/-   ##
==========================================
+ Coverage   54.19%   54.59%   +0.40%     
==========================================
  Files         101      102       +1     
  Lines       10983    11035      +52     
==========================================
+ Hits         5952     6025      +73     
+ Misses       4523     4504      -19     
+ Partials      508      506       -2     
```


| [Impacted Files](https://codecov.io/gh/cerbos/cerbos/pull/859?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cerbos) | Coverage Δ | |
|---|---|---|
| [cmd/cerbos/repl/internal/stack.go](https://codecov.io/gh/cerbos/cerbos/pull/859/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cerbos#diff-Y21kL2NlcmJvcy9yZXBsL2ludGVybmFsL3N0YWNrLmdv) | `84.21% <84.21%> (ø)` | |
| [cmd/cerbos/repl/internal/repl.go](https://codecov.io/gh/cerbos/cerbos/pull/859/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cerbos#diff-Y21kL2NlcmJvcy9yZXBsL2ludGVybmFsL3JlcGwuZ28=) | `48.00% <84.84%> (+3.13%)` | :arrow_up: |
| [internal/engine/engine.go](https://codecov.io/gh/cerbos/cerbos/pull/859/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cerbos#diff-aW50ZXJuYWwvZW5naW5lL2VuZ2luZS5nbw==) | `72.00% <100.00%> (+2.83%)` | :arrow_up: |
| [internal/audit/local/badgerdb.go](https://codecov.io/gh/cerbos/cerbos/pull/859/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cerbos#diff-aW50ZXJuYWwvYXVkaXQvbG9jYWwvYmFkZ2VyZGIuZ28=) | `63.17% <0.00%> (-0.64%)` | :arrow_down: |
| [internal/storage/index/builder.go](https://codecov.io/gh/cerbos/cerbos/pull/859/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cerbos#diff-aW50ZXJuYWwvc3RvcmFnZS9pbmRleC9idWlsZGVyLmdv) | `84.35% <0.00%> (+5.02%)` | :arrow_up: |
| [internal/compile/errors.go](https://codecov.io/gh/cerbos/cerbos/pull/859/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cerbos#diff-aW50ZXJuYWwvY29tcGlsZS9lcnJvcnMuZ28=) | `55.55% <0.00%> (+15.87%)` | :arrow_up: |


Patch:
diff --git a/cmd/cerbos/repl/internal/repl.go b/cmd/cerbos/repl/internal/repl.go
index 28fe02fb..69fa26b7 100644
--- a/cmd/cerbos/repl/internal/repl.go
+++ b/cmd/cerbos/repl/internal/repl.go
@@ -50,6 +50,12 @@ var (
 
 	listType = reflect.TypeOf([]any{})
 	mapType  = reflect.TypeOf(map[string]any{})
+
+	oppositeChars = map[rune]rune{
+		')': '(',
+		'}': '{',
+		']': '[',
+	}
 )
 
 const (
@@ -122,6 +128,7 @@ func (r *REPL) readInput() string {
 	var input strings.Builder
 	currPrompt := prompt
 
+	stack := &runeStack{}
 	for {
 		line, err := r.reader.Prompt(currPrompt)
 		if err != nil {
@@ -138,12 +145,13 @@ func (r *REPL) readInput() string {
 			return input.String()
 		}
 
-		if strings.HasSuffix(line, "\\") {
-			input.WriteString(strings.TrimSuffix(line, "\\"))
+		l, terminated := isTerminated(line, stack)
+		if !terminated {
+			input.WriteString(l)
 			input.WriteString(" ")
 			currPrompt = secondaryPrompt
 		} else {
-			input.WriteString(line)
+			input.WriteString(l)
 			return input.String()
 		}
 	}
@@ -534,6 +542,48 @@ func (r *REPL) mkEnv() (*cel.Env, error) {
 	return conditions.StdEnv.Extend(cel.Declarations(decls...))
 }
 
+func isTerminated(line string, stack *runeStack) (string, bool) {
+	if line[len(line)-1] == '\\' {
+		return line[:len(line)-1], false
+	}
+
+	inQuote := false
+	for idx, r := range line {
+		switch r {
+		case '"', '\'':
+			if idx-1 >= 0 && line[idx-1] == '\\' {
+				continue
+			}
+
+			if p, ok := stack.Peek(); ok {
+				if p == r {
+					stack.Pop()
+				}
+			} else {
+				stack.Push(r)
+			}
+
+			inQuote = !inQuote
+		case '(', '{', '[':
+			if inQuote {
+				continue
+			}
+			stack.Push(r)
+		case ')', '}', ']':
+			if inQuote {
+				continue
+			}
+			if p, ok := stack.Peek(); ok {
+				if oppositeChars[r] == p {
+					stack.Pop()
+				}
+			}
+		}
+	}
+
+	return line, stack.IsEmpty()
+}
+
 // variables is a type that provides the interpreter.Activation interface.
 type variables map[string]ref.Val
 
diff --git a/cmd/cerbos/repl/internal/stack.go b/cmd/cerbos/repl/internal/stack.go
new file mode 100644
index 00000000..ce721989
--- /dev/null
+++ b/cmd/cerbos/repl/internal/stack.go
@@ -0,0 +1,35 @@
+// Copyright 2021-2022 Zenauth Ltd.
+// SPDX-License-Identifier: Apache-2.0
+
+package internal
+
+type runeStack []rune
+
+func (s *runeStack) Push(r rune) {
+	*s = append(*s, r)
+}
+
+func (s *runeStack) Pop() (rune, bool) {
+	l := len(*s)
+	if l == 0 {
+		return -1, false
+	}
+
+	v := (*s)[l-1]
+	*s = (*s)[:l-1]
+
+	return v, true
+}
+
+func (s *runeStack) Peek() (rune, bool) {
+	l := len(*s)
+	if l == 0 {
+		return -1, false
+	}
+
+	return (*s)[l-1], true
+}
+
+func (s *runeStack) IsEmpty() bool {
+	return len(*s) == 0
+}


Test patch:
diff --git a/cmd/cerbos/repl/internal/repl_test.go b/cmd/cerbos/repl/internal/repl_test.go
index 4e135ab6..fd7ae46d 100644
--- a/cmd/cerbos/repl/internal/repl_test.go
+++ b/cmd/cerbos/repl/internal/repl_test.go
@@ -381,6 +381,40 @@ func TestREPL(t *testing.T) {
 	}
 }
 
+func TestIsTerminated(t *testing.T) {
+	testCases := []struct {
+		input    string
+		expected bool
+	}{
+		{"{foo: bar}", true},
+		{"[foo: bar]", true},
+		{"(foo: bar)", true},
+		{"{'foo': 'bar'}", true},
+		{"['foo': 'bar']", true},
+		{"('foo': 'bar')", true},
+		{"{\"foo\": \"bar\"}", true},
+		{"[\"foo\": \"bar\"]", true},
+		{"(\"foo\": \"bar\")", true},
+		{"{\"foo\": \"bar\"}", true},
+		{"foo\"bar\" ('baz')", true},
+		{"\"foo\\\"bar\\\" ('baz'\"", true},
+		{"\"foo_'_bar_\\\"_baz\"", true},
+		{"{foo: bar}\\", false},
+		{"foo_'_bar_\\\"_baz", false},
+		{"foo_'_bar_baz", false},
+		{"foo\\\"bar\\\" ('baz'", false},
+		{"{'foo': 'bar'", false},
+	}
+
+	for idx, tc := range testCases {
+		t.Run(fmt.Sprintf("TestCase_%d", idx), func(t *testing.T) {
+			stack := &runeStack{}
+			_, actual := isTerminated(tc.input, stack)
+			require.True(t, actual == tc.expected, tc.input)
+		})
+	}
+}
+
 type mockOutput struct {
 	msg        string
 	args       []any