Repository: vyperlang/vyper

Issue:
Unhandled Exception with Unbalanced Parens
### Version Information
* vyper Version: 0.2.0-beta.2

### What's your issue about?
Missing a parenthesis results in a somewhat intuitive error (for those who don't now how compilers work):
```python
@public
def foo():
    convert(

# Returns "tokenize.TokenError: ('EOF in multi-line statement', (4, 0))" when compiled
```

### How can it be fixed?
We should catch this error and parse the location so that we can more accurately report it to users.

#### Cute Animal Picture
![pug eating pizza](http://cdn6.whiskeyriff.com/wp-content/uploads/dog-eating-pizza.jpg)


Discussion:
On this.

Resolving patch:
diff --git a/vyper/exceptions.py b/vyper/exceptions.py
--- a/vyper/exceptions.py
+++ b/vyper/exceptions.py
@@ -34,7 +34,7 @@ def __str__(self):
                 col = '-' * self.col_offset + '^'
                 output += '\n' + col
 
-        elif self.lineno and self.col_offset:
+        elif self.lineno is not None and self.col_offset is not None:
             output = 'line %d:%d %s' % (
                 self.lineno,
                 self.col_offset,
diff --git a/vyper/parser/parser.py b/vyper/parser/parser.py
--- a/vyper/parser/parser.py
+++ b/vyper/parser/parser.py
@@ -1,15 +1,6 @@
 import ast
 import copy
-import tokenize
-import io
-import re
-
-from tokenize import (
-    OP,
-    NAME,
-    TokenInfo,
-    COMMENT
-)
+
 
 from vyper.exceptions import (
     InvalidLiteralException,
@@ -31,6 +22,7 @@
 from vyper.parser.context import Context
 from vyper.parser.global_context import GlobalContext
 from vyper.parser.lll_node import LLLnode
+from vyper.parser.pre_parser import pre_parse
 from vyper.parser.parser_utils import (
     add_variable_offset,
     base_type_conversion,
@@ -65,7 +57,6 @@
     bytes_to_int,
     calc_mem_gas,
 )
-from vyper import __version__
 
 
 if not hasattr(ast, 'AnnAssign'):
@@ -74,54 +65,13 @@
 
 # Converts code to parse tree
 def parse(code):
-    code = pre_parser(code)
+    code = pre_parse(code)
     o = ast.parse(code)
     decorate_ast_with_source(o, code)
     o = resolve_negative_literals(o)
     return o.body
 
 
-# Minor pre-parser checks.
-def pre_parser(code):
-    result = []
-
-    g = tokenize.tokenize(io.BytesIO(code.encode('utf-8')).readline)
-    for token in g:
-
-        # Alias contract definition to class definition.
-        if token.type == COMMENT and "@version" in token.string:
-            parse_version_pragma(token.string[1:])
-        if (token.type, token.string, token.start[1]) == (NAME, "contract", 0):
-            token = TokenInfo(token.type, "class", token.start, token.end, token.line)
-        # Prevent semi-colon line statements.
-        elif (token.type, token.string) == (OP, ";"):
-            raise StructureException("Semi-colon statements not allowed.", token.start)
-
-        result.append(token)
-    return tokenize.untokenize(result).decode('utf-8')
-
-
-def _parser_version_str(version_str):
-    version_regex = re.compile(r'^(\d+\.)?(\d+\.)?(\w*)$')
-    if None in version_regex.match(version_str).groups():
-        raise Exception('Could not parse given version: %s' % version_str)
-    return version_regex.match(version_str).groups()
-
-
-# Do a version check.
-def parse_version_pragma(version_str):
-    version_arr = version_str.split('@version')
-
-    file_version = version_arr[1].strip()
-    file_major, file_minor, file_patch = _parser_version_str(file_version)
-    compiler_major, compiler_minor, compiler_patch = _parser_version_str(__version__)
-
-    if (file_major, file_minor) != (compiler_major, compiler_minor):
-        raise Exception('Given version "{}" is not compatible with the compiler ({}): '.format(
-            file_version, __version__
-        ))
-
-
 # Header code
 initializer_list = ['seq', ['mstore', 28, ['calldataload', 0]]]
 # Store limit constants at fixed addresses in memory.
diff --git a/vyper/parser/pre_parser.py b/vyper/parser/pre_parser.py
new file mode 100644
--- /dev/null
+++ b/vyper/parser/pre_parser.py
@@ -0,0 +1,57 @@
+import io
+import re
+from tokenize import (
+    COMMENT,
+    NAME,
+    OP,
+    TokenError,
+    TokenInfo,
+    tokenize,
+    untokenize,
+)
+from vyper.exceptions import StructureException
+from vyper import __version__
+
+
+def _parser_version_str(version_str):
+    version_regex = re.compile(r'^(\d+\.)?(\d+\.)?(\w*)$')
+    if None in version_regex.match(version_str).groups():
+        raise Exception('Could not parse given version: %s' % version_str)
+    return version_regex.match(version_str).groups()
+
+
+# Do a version check.
+def parse_version_pragma(version_str):
+    version_arr = version_str.split('@version')
+
+    file_version = version_arr[1].strip()
+    file_major, file_minor, file_patch = _parser_version_str(file_version)
+    compiler_major, compiler_minor, compiler_patch = _parser_version_str(__version__)
+
+    if (file_major, file_minor) != (compiler_major, compiler_minor):
+        raise Exception('Given version "{}" is not compatible with the compiler ({}): '.format(
+            file_version, __version__
+        ))
+
+
+# Minor pre-parser checks.
+def pre_parse(code):
+    result = []
+
+    try:
+        g = tokenize(io.BytesIO(code.encode('utf-8')).readline)
+        for token in g:
+            # Alias contract definition to class definition.
+            if token.type == COMMENT and "@version" in token.string:
+                parse_version_pragma(token.string[1:])
+            if (token.type, token.string, token.start[1]) == (NAME, "contract", 0):
+                token = TokenInfo(token.type, "class", token.start, token.end, token.line)
+            # Prevent semi-colon line statements.
+            elif (token.type, token.string) == (OP, ";"):
+                raise StructureException("Semi-colon statements not allowed.", token.start)
+
+            result.append(token)
+    except TokenError as e:
+        raise StructureException(e.args[0], e.args[1]) from e
+
+    return untokenize(result).decode('utf-8')


Test patch:
diff --git a/tests/compiler/test_pre_parser.py b/tests/compiler/test_pre_parser.py
--- a/tests/compiler/test_pre_parser.py
+++ b/tests/compiler/test_pre_parser.py
@@ -97,3 +97,14 @@ def test():
     pass
     """
     assert_compile_failed(lambda: get_contract(code))
+
+
+def test_unbalanced_parens(assert_compile_failed, get_contract):
+    code = """
+@public
+def foo():
+    convert(
+    """
+
+    with raises(StructureException):
+        get_contract(code)