Repository: CloudyKit/jet
Pull request #168 (2020-07-21 16:28:07)

detect '_' as special discard syntax in assignments
This adds Go-like handling of `_` in assignments, skipping creating or updating a variable reference but still evaluating the corresponding right side of the assignment. The updated docs explain it well:

> Assigning anything to `_` tells Jet to evalute the right side, but skip the actual assignment to a new or existing identifier. This is useful to call a function but discard its return value:

>     {{ _ := stillRuns() }}
>     {{ _ = stillRuns() }}

> Since no actual assigning takes place, both of the above are equivalent: `stillRuns` is executed, but the return value will neither be stored in a variable, nor will it be rendered (unlike `{{ stillRuns() }}`, which would render the return value to the output).


Patch:
diff --git a/constructors.go b/constructors.go
index 6ffb562..8f3ed99 100644
--- a/constructors.go
+++ b/constructors.go
@@ -238,3 +238,7 @@ func (t *Template) newNumber(pos Pos, text string, typ itemType) (*NumberNode, e
 func (t *Template) newIdentifier(ident string, pos Pos, line int) *IdentifierNode {
 	return &IdentifierNode{NodeBase: NodeBase{TemplatePath: t.Name, NodeType: NodeIdentifier, Pos: pos, Line: line}, Ident: ident}
 }
+
+func (t *Template) newDiscard(pos Pos, line int) *DiscardNode {
+	return &DiscardNode{NodeBase: NodeBase{TemplatePath: t.Name, NodeType: NodeDiscard, Pos: pos, Line: line}}
+}
diff --git a/docs/changes.md b/docs/changes.md
index 5f265fd..260d383 100644
--- a/docs/changes.md
+++ b/docs/changes.md
@@ -1,5 +1,14 @@
 # Breaking Changes
 
+## v5
+
+- `_` discard syntax in assignments
+
+    Version 5 adds Go-like discard syntax in assignments: assigning anything to `_` will make jet skip the assignment. Jet will still always evaluate the corresponding right-hand side of the assignment statement, i.e. you can use `_` to call a function but throw away its return value.
+
+    When you assign (and/or use) a variable called `_` in your code, you will have to rename this variable.
+
+
 ## v4
 
 When updating from version 3 to version 4, there are a few breaking changes:
diff --git a/docs/syntax.md b/docs/syntax.md
index 42bcd10..7ea8439 100644
--- a/docs/syntax.md
+++ b/docs/syntax.md
@@ -78,6 +78,12 @@ Variables initialised inside a template have no fixed type, so this is valid, to
 
     {{ foo = 4711 }}
 
+Assigning anything to `_` tells Jet to evalute the right side, but skip the actual assignment to a new or existing identifier. This is useful to call a function but discard its return value:
+
+    {{ _ := stillRuns() }}
+    {{ _ = stillRuns() }}
+
+Since no actual assigning takes place, both of the above are equivalent: `stillRuns` is executed, but the return value will neither be stored in a variable, nor will it be rendered (unlike `{{ stillRuns() }}`, which would render the return value to the output).
 
 ## Expressions
 
diff --git a/eval.go b/eval.go
index 91e14d5..c42d1f1 100644
--- a/eval.go
+++ b/eval.go
@@ -284,15 +284,22 @@ RESTART:
 func (st *Runtime) executeSetList(set *SetNode) {
 	if set.IndexExprGetLookup {
 		value := st.evalPrimaryExpressionGroup(set.Right[0])
-		st.executeSet(set.Left[0], value)
-		if value.IsValid() {
-			st.executeSet(set.Left[1], valueBoolTRUE)
-		} else {
-			st.executeSet(set.Left[1], valueBoolFALSE)
+		if set.Left[0].Type() != NodeDiscard {
+			st.executeSet(set.Left[0], value)
+		}
+		if set.Left[1].Type() != NodeDiscard {
+			if value.IsValid() {
+				st.executeSet(set.Left[1], valueBoolTRUE)
+			} else {
+				st.executeSet(set.Left[1], valueBoolFALSE)
+			}
 		}
 	} else {
 		for i := 0; i < len(set.Left); i++ {
-			st.executeSet(set.Left[i], st.evalPrimaryExpressionGroup(set.Right[i]))
+			value := st.evalPrimaryExpressionGroup(set.Right[i])
+			if set.Left[i].Type() != NodeDiscard {
+				st.executeSet(set.Left[i], value)
+			}
 		}
 	}
 }
@@ -300,18 +307,21 @@ func (st *Runtime) executeSetList(set *SetNode) {
 func (st *Runtime) executeLetList(set *SetNode) {
 	if set.IndexExprGetLookup {
 		value := st.evalPrimaryExpressionGroup(set.Right[0])
-
-		st.variables[set.Left[0].(*IdentifierNode).Ident] = value
-
-		if value.IsValid() {
-			st.variables[set.Left[1].(*IdentifierNode).Ident] = valueBoolTRUE
-		} else {
-			st.variables[set.Left[1].(*IdentifierNode).Ident] = valueBoolFALSE
+		if set.Left[0].Type() != NodeDiscard {
+			st.variables[set.Left[0].(*IdentifierNode).Ident] = value
+		}
+		if set.Left[1].Type() != NodeDiscard {
+			if value.IsValid() {
+				st.variables[set.Left[1].(*IdentifierNode).Ident] = valueBoolTRUE
+			} else {
+				st.variables[set.Left[1].(*IdentifierNode).Ident] = valueBoolFALSE
+			}
 		}
-
 	} else {
 		for i := 0; i < len(set.Left); i++ {
-			st.variables[set.Left[i].(*IdentifierNode).Ident] = st.evalPrimaryExpressionGroup(set.Right[i])
+			if set.Left[i].Type() != NodeDiscard {
+				st.variables[set.Left[i].(*IdentifierNode).Ident] = st.evalPrimaryExpressionGroup(set.Right[i])
+			}
 		}
 	}
 }
diff --git a/lex.go b/lex.go
index e473301..550ef99 100644
--- a/lex.go
+++ b/lex.go
@@ -82,6 +82,7 @@ const (
 	itemTernary
 	itemLeftBrackets
 	itemRightBrackets
+	itemUnderscore
 	// Keywords appear after all the rest.
 	itemKeyword // used only to delimit the keywords
 	itemExtends
@@ -346,7 +347,7 @@ func lexInsideAction(l *lexer) stateFn {
 		if l.parenDepth == 0 {
 			return lexRightDelim
 		}
-		return l.errorf("unclosed left paren")
+		return l.errorf("unclosed left parenthesis")
 	}
 	switch r := l.next(); {
 	case r == eof:
@@ -467,6 +468,12 @@ func lexInsideAction(l *lexer) stateFn {
 	case '0' <= r && r <= '9':
 		l.backup()
 		return lexNumber
+	case r == '_':
+		if isSpace(l.peek()) {
+			l.emit(itemUnderscore)
+			return lexInsideAction
+		}
+		fallthrough // no space? must be the start of an identifier
 	case isAlphaNumeric(r):
 		l.backup()
 		return lexIdentifier
@@ -485,7 +492,6 @@ func lexInsideAction(l *lexer) stateFn {
 		}
 	case r <= unicode.MaxASCII && unicode.IsPrint(r):
 		l.emit(itemChar)
-		return lexInsideAction
 	default:
 		return l.errorf("unrecognized character in action: %#U", r)
 	}
diff --git a/node.go b/node.go
index d8f7afb..b0c53ac 100644
--- a/node.go
+++ b/node.go
@@ -77,6 +77,7 @@ const (
 	NodeCommand                    //An element of a pipeline.
 	NodeField                      //A field or method name.
 	NodeIdentifier                 //An identifier; always a function name.
+	NodeDiscard                    //An underscore
 	NodeList                       //A list of Nodes.
 	NodePipe                       //A pipeline of commands.
 	NodeSet
@@ -215,6 +216,15 @@ func (i *IdentifierNode) String() string {
 	return i.Ident
 }
 
+// DiscardNode signals to discard the corresponding right side of an assignment.
+type DiscardNode struct {
+	NodeBase
+}
+
+func (i *DiscardNode) String() string {
+	return "_"
+}
+
 // NilNode holds the special identifier 'nil' representing an untyped nil constant.
 type NilNode struct {
 	NodeBase
diff --git a/parse.go b/parse.go
index a9f56d8..86a93e7 100644
--- a/parse.go
+++ b/parse.go
@@ -619,7 +619,7 @@ func (t *Template) assignmentOrExpression(context string) (operand Expression) {
 	leftloop:
 		for {
 			switch operand.Type() {
-			case NodeField, NodeChain, NodeIdentifier:
+			case NodeField, NodeChain, NodeIdentifier, NodeDiscard:
 				left = append(left, operand)
 			default:
 				t.errorf("unexpected node in assign")
@@ -638,7 +638,7 @@ func (t *Template) assignmentOrExpression(context string) (operand Expression) {
 
 		if isLet {
 			for _, operand := range left {
-				if operand.Type() != NodeIdentifier {
+				if operand.Type() != NodeIdentifier && operand.Type() != NodeDiscard {
 					t.errorf("unexpected node type %s in variable declaration", operand)
 				}
 			}
@@ -974,6 +974,8 @@ func (t *Template) term() Node {
 		t.errorf("%s", token.val)
 	case itemIdentifier:
 		return t.newIdentifier(token.val, token.pos, t.lex.lineNumber())
+	case itemUnderscore:
+		return t.newDiscard(token.pos, t.lex.lineNumber())
 	case itemNil:
 		return t.newNil(token.pos)
 	case itemField:


Test patch:
diff --git a/eval_test.go b/eval_test.go
index 897de4e..74898b5 100644
--- a/eval_test.go
+++ b/eval_test.go
@@ -184,7 +184,7 @@ func TestEvalActionNode(t *testing.T) {
 	RunJetTest(t, data, nil, "actionNode_NumberNegative", `{{ -5 }}`, "-5")
 	RunJetTest(t, data, nil, "actionNode_NumberNegative_1", `{{ 1 + -5 }}`, fmt.Sprint(1+-5))
 
-	//this is an error RunJetTest(t, data, nil, "actionNode_AddStringInt", `{{ "1"-2 }}`, "12")
+	//this must be an error RunJetTest(t, data, nil, "actionNode_AddStringInt", `{{ "1"-2 }}`, "12")
 
 	RunJetTest(t, data, nil, "actionNode_Mult", `{{ 4*4 }}`, fmt.Sprint(4*4))
 	RunJetTest(t, data, nil, "actionNode_MultAdd", `{{ 2+4*4 }}`, fmt.Sprint(2+4*4))
@@ -203,6 +203,41 @@ func TestEvalActionNode(t *testing.T) {
 	RunJetTest(t, data, nil, "actionNode_NumericCmp", `{{ 5*5 > 2*12.5 }}`, fmt.Sprint(5*5 > 2*12.5))
 	RunJetTest(t, data, nil, "actionNode_NumericCmp1", `{{ 5*5 >= 2*12.5 }}`, fmt.Sprint(5*5 >= 2*12.5))
 	RunJetTest(t, data, nil, "actionNode_NumericCmp1", `{{ 5 * 5 > 2 * 12.5 == 5 * 5 > 2 * 12.5 }}`, fmt.Sprint((5*5 > 2*12.5) == (5*5 > 2*12.5)))
+
+	//
+	// assignments
+	//
+
+	called := false
+	markCalled := func() int { called = true; return 123 }
+	data.Set("mark", markCalled)
+
+	setup := func() {
+		called = false
+		data.Set("called", &called)
+	}
+
+	tearDown := func() {
+		delete(data, "called")
+	}
+
+	setup()
+	// test discard syntax
+	RunJetTest(t, data, nil, "actionNode_assign_discard", `{{ _ = mark() ; called }}`, "true")
+	if !called {
+		t.Log("function whose value should be evaluated but discarded was never called!")
+		t.Fail()
+	}
+	if _, ok := data["_"]; ok {
+		t.Log("a variable with name '_' was set!")
+		t.Fail()
+	}
+	tearDown()
+
+	setup()
+	// test identifier starting with '_'
+	RunJetTest(t, data, nil, "actionNode_assign_underscore", `{{ _foo := mark() ; called && _foo == 123 }}`, "true")
+	tearDown()
 }
 
 func TestEvalIfNode(t *testing.T) {
diff --git a/lex_test.go b/lex_test.go
index f3f4176..da22a8d 100644
--- a/lex_test.go
+++ b/lex_test.go
@@ -65,6 +65,7 @@ func TestLexer(t *testing.T) {
 	lexerTestCase(t, `{{.Ex!1}}`, itemLeftDelim, itemField, itemNot, itemNumber, itemRightDelim)
 	lexerTestCase(t, `{{.Ex==1}}`, itemLeftDelim, itemField, itemEquals, itemNumber, itemRightDelim)
 	lexerTestCase(t, `{{.Ex&&1}}`, itemLeftDelim, itemField, itemAnd, itemNumber, itemRightDelim)
+	lexerTestCase(t, `{{ _ = foo }}`, itemLeftDelim, itemUnderscore, itemAssign, itemIdentifier, itemRightDelim)
 }
 
 func TestCustomDelimiters(t *testing.T) {
diff --git a/testData/assignment.jet b/testData/assignment.jet
index 076d13c..288d5b0 100644
--- a/testData/assignment.jet
+++ b/testData/assignment.jet
@@ -2,8 +2,14 @@
 {{ newName := name; safeHtml: newName, " ", "new name" }}
 {{ newName,newValue := name,value }}
 {{ value,found := name["key"] }}
+{{ _ := foo() }}
+{{ _ = foo() }}
+{{ _, _ = name["key"] }}
 ===
 {{newURL:=url("", "").Method("");newURL | pipe}}
 {{newName:=name;safeHtml(newName, " ", "new name")}}
 {{newName, newValue:=name, value}}
-{{value, found:=name["key"]}}
\ No newline at end of file
+{{value, found:=name["key"]}}
+{{_:=foo()}}
+{{_=foo()}}
+{{_, _=name["key"]}}
\ No newline at end of file