Repository: psychopy/psychopy

Issue:
$ sign gets removed from JS code component
The standard PsychoJS web page includes jQuery, so I tried to use $('somethingsomething') in a JS code component to select an HTML element. The $ is still there in the .psyexp file, but it got removed when running the task via PsychoJS.


Discussion:
Update: I manually edited the psyexp file to see if it would work with &dollar; or &#36;. No love
The issue here is the generation of the JS from the Builder, right? That's an issue for the PsychoPy repo (that @TEParsons should be able to handle) so I'll move it to there.
Removing the dollar sign from `text` fields is appropriate (because the $ indicates that it should be treated as code) but a code component should not remove any chars so we need to fix that one
This looks deliberate, here's the code where it happens:
```
            if isStr and self.val.startswith("$"):
                # a $ in a code parameter is unnecessary so remove it
                val = "%s" % self.val[1:]
            elif isStr and self.val.startswith("\$"):
                # the user actually wanted just the $
                val = "%s" % self.val[1:]
```
Should be as simple as removing this code, but just wanted to check that we definitely want this gone?
Actually yes, I think we should talk about this more. We've historically stripped the $ even from code fields because people would forget and add a $. This was fine in Python because it's rare that people need a $ in their code but I guess it's a problem if the $ in JS has a use/meaning. I'm now wishing we had used § to indicate code (since I've never used that anywhere else!)

Resolving patch:
diff --git a/psychopy/alerts/alerttools.py b/psychopy/alerts/alerttools.py
--- a/psychopy/alerts/alerttools.py
+++ b/psychopy/alerts/alerttools.py
@@ -1,4 +1,6 @@
 import ast
+import re
+
 from numpy import array
 from esprima import parseScript
 
@@ -258,6 +260,21 @@ def testDisabled(component):
     if component.params['disabled'].val:
         alert(4305, component)
 
+def testDollarSyntax(component):
+    """
+    Tests that use of dollar signs in Builder components to denote literal interpretation are used correctly
+
+    Parameters
+    ----------
+    component: Component
+        The component used for testing
+    """
+    valid = {}
+    for (key, param) in component.params.items():
+        if not param.dollarSyntax()[0]:
+            alert(4315, strFields={'component': component, 'param': param})
+    return valid
+
 def checkPythonSyntax(component, tab):
     """
     Checks each Python code component tabs for syntax errors.
diff --git a/psychopy/experiment/components/_base.py b/psychopy/experiment/components/_base.py
--- a/psychopy/experiment/components/_base.py
+++ b/psychopy/experiment/components/_base.py
@@ -125,6 +125,7 @@ def integrityCheck(self):
         """
         alerttools.testDisabled(self)
         alerttools.testStartEndTiming(self)
+        alerttools.testDollarSyntax(self)
 
     def _dubiousConstantUpdates(self):
         """Return a list of fields in component that are set to be constant
@@ -437,8 +438,6 @@ def writeParamUpdate(self, buff, compName, paramName, val, updateType,
         # code conversions for PsychoJS
         if target == 'PsychoJS':
             endStr = ';'
-            if isinstance(val, Param):
-                val = val.val
             try:
                 valStr = str(val).strip()
             except TypeError:
diff --git a/psychopy/experiment/params.py b/psychopy/experiment/params.py
--- a/psychopy/experiment/params.py
+++ b/psychopy/experiment/params.py
@@ -28,6 +28,7 @@
 from . import py2js
 
 # standard_library.install_aliases()
+from ..alerts import alert
 
 
 def _findParam(name, node):
@@ -139,6 +140,7 @@ def __init__(self, val, valType, allowedVals=None, allowedTypes=None,
         self.staticUpdater = None
         self.categ = categ
         self.readOnly = False
+        self.codeWanted = False
 
     def __str__(self):
         if self.valType == 'num':
@@ -158,24 +160,25 @@ def __str__(self):
             # return repr if str wanted; this neatly handles "it's" and 'He
             # says "hello"'
             if isinstance(self.val, basestring):
-                codeWanted = utils.unescapedDollarSign_re.search(self.val)
-                if codeWanted:
+                valid, val = self.dollarSyntax()
+                if self.codeWanted and valid:
+                    # If code is wanted, return code (translated to JS if needed)
                     if utils.scriptTarget == 'PsychoJS':
-                        valJS = py2js.expression2js(self.val.strip('$'))
+                        valJS = py2js.expression2js(val)
                         if self.val != valJS:
                             logging.debug("Rewriting with py2js: {} -> {}".format(self.val, valJS))
                         return valJS
                     else:
-                        return "%s" % getCodeFromParamStr(self.val)
-                else:  # str wanted
-                    # remove \ from all \$
-                    s = repr(re.sub(r"[\\]\$", '$', self.val))
-                    # if target is python2.x then unicode will be u'something'
-                    # but for other targets that will raise an annoying error
+                        return val
+                else:
+                    # If str is wanted, return literal
                     if utils.scriptTarget != 'PsychoPy':
-                        if s.startswith("u'") or s.startswith('u"'):
-                            s = s[1:]
-                    return s
+                        if val.startswith("u'") or val.startswith('u"'):
+                            # if target is python2.x then unicode will be u'something'
+                            # but for other targets that will raise an annoying error
+                            val = val[1:]
+                    val=re.sub("\n", "\\\\n", val) # Replace line breaks with escaped line break character
+                    return f"\"{val}\""
             return repr(self.val)
         elif self.valType in ['code', 'extendedCode']:
             isStr = isinstance(self.val, basestring)
@@ -231,6 +234,46 @@ def __bool__(self):
         rather than `if thisParam.val`"""
         return bool(self.val)
 
+    def dollarSyntax(self):
+        """
+        Interpret string according to dollar syntax, return:
+        1: Whether syntax is valid (True/False)
+        2: Whether code is wanted (True/False)
+        3: The value, stripped of any unnecessary $
+        """
+        val = self.val
+        if self.valType in ["str", "extendedStr"]:
+            # How to handle dollar signs in a string param
+            self.codeWanted = val.startswith("$")
+
+            if not re.search(r"\$", str(val)):
+                # Return if there are no $
+                return True, val
+            if self.codeWanted:
+                # If value begins with an unescaped $, remove the first char and treat the rest as code
+                val = val[1:]
+                inComment = "".join(re.findall("\#.*", val))
+                inQuotes = "".join(re.findall("[\'\"][^\"|^\']*[\'\"]", val))
+                if not re.findall(r"\$", val):
+                    # Return if there are no further dollar signs
+                    return True, val
+                if len(re.findall(r"\$", val)) == len(re.findall(r"\$", inComment)):
+                    # Return if all $ are commented out
+                    return True, val
+                if len(re.findall(r"\$", val)) - len(re.findall(r"\$", inComment)) == len(re.findall(r"\\\$", inQuotes)):
+                    # Return if all non-commended $ are in strings and escaped
+                    return True, val
+            else:
+                # If value does not begin with an unescaped $, treat it as a string
+                if not re.findall(r"(?<!\\)\$", val):
+                    # Return if all $ are escaped (\$)
+                    return True, val
+        else:
+            # If valType does not interact with $, return True
+            return True, val
+        # Return false if method has not returned yet
+        return False, val
+
     __nonzero__ = __bool__  # for python2 compatibility
 
 


Test patch:
diff --git a/psychopy/tests/test_alerts/test_alerttools.py b/psychopy/tests/test_alerts/test_alerttools.py
--- a/psychopy/tests/test_alerts/test_alerttools.py
+++ b/psychopy/tests/test_alerts/test_alerttools.py
@@ -101,7 +101,7 @@ def test_4115_frames_as_int(self):
         self.polygonComp.params['startVal'].val = .5
         self.polygonComp.params['startType'].val = "duration (frames)"
         self.exp.integrityCheck()
-        assert ("Your stimulus start type 'duration (frames)' must be expressed as a whole number" in self.error.alerts[0].msg)
+        assert ("Your stimulus start type \"duration (frames)\" must be expressed as a whole number" in self.error.alerts[0].msg)
 
     def test_python_syntax(self):
         alerttools.checkPythonSyntax(self.codeComp, 'Begin Experiment')
diff --git a/psychopy/tests/test_scripts/test_psyexpCompile.py b/psychopy/tests/test_scripts/test_psyexpCompile.py
--- a/psychopy/tests/test_scripts/test_psyexpCompile.py
+++ b/psychopy/tests/test_scripts/test_psyexpCompile.py
@@ -4,6 +4,9 @@
 from tempfile import mkdtemp
 from psychopy.scripts import psyexpCompile
 from psychopy.tests.utils import TESTS_DATA_PATH
+from psychopy.alerts import alerttools
+from psychopy.experiment.components._base import BaseComponent
+from psychopy.experiment.params import Param
 
 
 class TestComponents(object):
@@ -63,4 +66,28 @@ def test_all_code_component_tabs(self):
         assert '___begin_routine___;' in script
         assert '___each_frame___;' in script
         assert '___end_routine___;' in script
-        assert '___end_experiment___;' in script
\ No newline at end of file
+        assert '___end_experiment___;' in script
+
+    def test_dollar_sign_syntax(self):
+        # Define several "tykes" - values which are likely to cause confusion - along with whether or not they are valid syntax
+        tykes = {
+            "$hello $there": False,
+            "$hello \$there": False,
+            "hello $there": False,
+            "\$hello there": True,
+            "#$hello there": False,
+            "$#hello there": True,
+            "$hello #there": True,
+            "$hello #$there": True,
+            "$hello \"\$there\"": True,
+            "$hello \'\$there\'": True,
+        }
+        # Make a component with a str parameter for each tyke
+        tykeComponent = BaseComponent(None, None)
+        for (i, val) in enumerate(tykes):
+            tykeComponent.params.update({
+                str(i): Param(val, "str")
+            })
+        for (i, val) in enumerate(tykes):
+            # Check the validity of each tyke param against the expected value
+            assert tykeComponent.params[str(i)].dollarSyntax()[0] == tykes[val]
\ No newline at end of file