From aa72445062d9ca4880fd3f415a17ccf255efee60 Mon Sep 17 00:00:00 2001
From: Fabien Arcellier <fabien.arcellier@gmail.com>
Date: Mon, 23 Sep 2024 09:55:38 +0200
Subject: [PATCH] feat: the WF engine should resolves escaped variables

* feat: handle escape variable in exchange protocol
* feat: handle escape variable in auto-completion
* feat: state support dot variable
---
 src/ui/src/builder/BuilderTemplateInput.vue | 16 ++++++++++---
 src/ui/src/renderer/useEvaluator.ts         | 12 ++++++++--
 src/writer/core.py                          | 26 ++++++++++++++-------
 tests/backend/test_core.py                  |  8 ++++---
 4 files changed, 45 insertions(+), 17 deletions(-)

diff --git a/src/ui/src/builder/BuilderTemplateInput.vue b/src/ui/src/builder/BuilderTemplateInput.vue
index 0605d78cd..1cc5ab0c8 100644
--- a/src/ui/src/builder/BuilderTemplateInput.vue
+++ b/src/ui/src/builder/BuilderTemplateInput.vue
@@ -114,7 +114,9 @@ const handleComplete = (selectedText) => {
 	const full = getPath(text);
 	if (full === null) return;
 	const keyword = full.at(-1);
-	const replaced = text.replace(new RegExp(keyword + "$"), selectedText);
+	const regexKeyword = keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "$"; // escape the keyword to handle properly on a regex
+	const replaced = text.replace(new RegExp(regexKeyword), selectedText);
+
 	newValue = replaced + newValue.slice(selectionEnd);
 	emit("input", { target: { value: newValue } });
 	emit("update:value", newValue);
@@ -144,6 +146,13 @@ const getPath = (text) => {
 	return raw.split(".");
 };
 
+/**
+ * Escape a key to support the "." and "\" in a state variable
+ */
+const escapeVariable = (key) => {
+	return key.replace("\\", "\\\\").replace(".", "\\.");
+};
+
 const handleInput = (ev) => {
 	emit("input", ev);
 	emit("update:value", ev.target.value);
@@ -168,7 +177,7 @@ const showAutocomplete = () => {
 
 	const allOptions = Object.entries(_get(ss.getUserState(), path) ?? {}).map(
 		([key, val]) => ({
-			text: key,
+			text: escapeVariable(key),
 			type: typeToString(val),
 		}),
 	);
@@ -196,8 +205,9 @@ function closeAutocompletion() {
 	closeAutocompletionJob = setTimeout(() => {
 		autocompleteOptions.value = [];
 		closeAutocompletionJob = null;
-	}, 100);
+	}, 300);
 }
+
 function abortClosingAutocompletion() {
 	if (!closeAutocompletionJob) return;
 	clearTimeout(closeAutocompletionJob);
diff --git a/src/ui/src/renderer/useEvaluator.ts b/src/ui/src/renderer/useEvaluator.ts
index 69c6199b8..ca2a1a1a1 100644
--- a/src/ui/src/renderer/useEvaluator.ts
+++ b/src/ui/src/renderer/useEvaluator.ts
@@ -17,9 +17,15 @@ export function useEvaluator(wf: Core) {
 			s = "";
 		let level = 0;
 
-		for (let i = 0; i < expr.length; i++) {
+		let i = 0
+		while (i < expr.length) {
 			const c = expr.charAt(i);
-			if (c == ".") {
+			if (c == "\\") {
+				if (i + 1 < expr.length) {
+					s += expr.charAt(i + 1);
+					i++;
+				}
+			} else if (c == ".") {
 				if (level == 0) {
 					accessors.push(s);
 					s = "";
@@ -44,6 +50,8 @@ export function useEvaluator(wf: Core) {
 			} else {
 				s += c;
 			}
+
+			i++
 		}
 
 		if (s) {
diff --git a/src/writer/core.py b/src/writer/core.py
index 2f3ddece9..8a4417d87 100644
--- a/src/writer/core.py
+++ b/src/writer/core.py
@@ -873,7 +873,7 @@ def calculated_property(self,
 
         for p in path_list:
             state_proxy = self._state_proxy
-            path_parts = p.split(".")
+            path_parts = parse_state_variable_expression(p)
             for i, path_part in enumerate(path_parts):
                 if i == len(path_parts) - 1:
                     local_mutation = MutationSubscription('property', p, handler, self, property_name)
@@ -1507,28 +1507,36 @@ def parse_expression(self, expr: str, instance_path: Optional[InstancePath] = No
         s = ""
         level = 0
 
-        for c in expr:
-            if c == ".":
+        i = 0
+        while i < len(expr):
+            character = expr[i]
+            if character == "\\":
+                if i + 1 < len(expr):
+                    s += expr[i + 1]
+                    i += 1
+            elif character == ".":
                 if level == 0:
                     accessors.append(s)
                     s = ""
                 else:
-                    s += c
-            elif c == "[":
+                    s += character
+            elif character == "[":
                 if level == 0:
                     accessors.append(s)
                     s = ""
                 else:
-                    s += c
+                    s += character
                 level += 1
-            elif c == "]":
+            elif character == "]":
                 level -= 1
                 if level == 0:
                     s = str(self.evaluate_expression(s, instance_path))
                 else:
-                    s += c
+                    s += character
             else:
-                s += c
+                s += character
+
+            i += 1
 
         if s:
             accessors.append(s)
diff --git a/tests/backend/test_core.py b/tests/backend/test_core.py
index 39c008b6f..9a7ff7ffd 100644
--- a/tests/backend/test_core.py
+++ b/tests/backend/test_core.py
@@ -56,7 +56,8 @@
     "counter": 4,
     "_private": 3,
     # Used as an example of something unserialisable yet pickable
-    "_private_unserialisable": np.array([[1+2j, 2, 3+3j]])
+    "_private_unserialisable": np.array([[1+2j, 2, 3+3j]]),
+    "a.b": 3
 }
 
 simple_dict = {"items": {
@@ -157,7 +158,8 @@ def test_apply_mutation_marker(self) -> None:
             '+interests': ['lamps', 'cars'],
             '+name': 'Robert',
             '+state\\.with\\.dots': None,
-            '+utfࠀ': 23
+            '+utfࠀ': 23,
+            '+a\.b': 3
         }
 
         self.sp_simple_dict.apply_mutation_marker()
@@ -1135,6 +1137,7 @@ def test_evaluate_expression(self) -> None:
         assert e.evaluate_expression("features.eyes", instance_path) == "green"
         assert e.evaluate_expression("best_feature", instance_path) == "eyes"
         assert e.evaluate_expression("features[best_feature]", instance_path) == "green"
+        assert e.evaluate_expression("a\.b", instance_path) == 3
 
     def test_get_context_data_should_return_the_target_of_event(self) -> None:
         """
@@ -1186,7 +1189,6 @@ def test_get_context_data_should_return_the_repeater_position_and_the_target_ins
         assert context.get("item") == "b"
         assert context.get("value") == "B"
 
-
 class TestSessionManager:
 
     sm = SessionManager()