Repository: erikrose/parsimonious

Issue:
Don't allow recursion if no characters are being used up.
I was trying to write left associative operators in grammar, but an issue that kept coming up was infinite recursion. This could be solved by making every operator right associative, because the parser would cut out the option of just going down the same node over and over. Making it so that the parser can only visit one node twice if characters have been used up would completely solve this issue.

Comments:

Patch:
diff --git a/README.rst b/README.rst
index afa55a6..fab1b16 100644
--- a/README.rst
+++ b/README.rst
@@ -443,6 +443,7 @@ Version History
 ===============
 
 (Next release)
+  * Improve error message in left-recursive rules. (lucaswiman)
   * Add support for range ``{min,max}`` repetition expressions (righthandabacus)
   * Fix bug in ``*`` and ``+`` for token grammars (lucaswiman)
   * Add support for grammars on bytestrings (lucaswiman)
diff --git a/parsimonious/exceptions.py b/parsimonious/exceptions.py
index d0f9375..a03a549 100644
--- a/parsimonious/exceptions.py
+++ b/parsimonious/exceptions.py
@@ -1,3 +1,4 @@
+from textwrap import dedent
 
 from parsimonious.utils import StrAndRepr
 
@@ -44,6 +45,20 @@ class ParseError(StrAndRepr, Exception):
             return self.pos + 1
 
 
+class LeftRecursionError(ParseError):
+    def __str__(self):
+        rule_name = self.expr.name if self.expr.name else str(self.expr)
+        window = self.text[self.pos:self.pos + 20]
+        return dedent(f"""
+            Left recursion in rule {rule_name!r} at {window!r} (line {self.line()}, column {self.column()}).
+
+            Parsimonious is a packrat parser, so it can't handle left recursion.
+            See https://en.wikipedia.org/wiki/Parsing_expression_grammar#Indirect_left_recursion
+            for how to rewrite your grammar into a rule that does not use left-recursion.
+            """
+        ).strip()
+
+
 class IncompleteParseError(ParseError):
     """A call to ``parse()`` matched a whole Expression but did not consume the
     entire text."""
diff --git a/parsimonious/expressions.py b/parsimonious/expressions.py
index 9200365..f93f2c6 100644
--- a/parsimonious/expressions.py
+++ b/parsimonious/expressions.py
@@ -10,7 +10,7 @@ from collections import defaultdict
 from inspect import getfullargspec, isfunction, ismethod, ismethoddescriptor
 import regex as re
 
-from parsimonious.exceptions import ParseError, IncompleteParseError
+from parsimonious.exceptions import ParseError, IncompleteParseError, LeftRecursionError
 from parsimonious.nodes import Node, RegexNode
 from parsimonious.utils import StrAndRepr
 
@@ -96,6 +96,9 @@ def expression(callable, rule_name, grammar):
     return AdHocExpression(name=rule_name)
 
 
+IN_PROGRESS = object()
+
+
 class Expression(StrAndRepr):
     """A thing that can be matched against a piece of text"""
 
@@ -186,10 +189,10 @@ class Expression(StrAndRepr):
             node = expr_cache[pos]
         else:
             # TODO: Set default value to prevent infinite recursion in left-recursive rules.
-            node = expr_cache[pos] = self._uncached_match(text,
-                                                          pos,
-                                                          cache,
-                                                          error)
+            expr_cache[pos] = IN_PROGRESS  # Mark as in progress
+            node = expr_cache[pos] = self._uncached_match(text, pos, cache, error)
+        if node is IN_PROGRESS:
+            raise LeftRecursionError(text, pos=-1, expr=self)
 
         # Record progress for error reporting:
         if node is None and pos >= error.pos and (


Test patch:
diff --git a/parsimonious/tests/test_grammar.py b/parsimonious/tests/test_grammar.py
index b4ac7f7..2f979f6 100644
--- a/parsimonious/tests/test_grammar.py
+++ b/parsimonious/tests/test_grammar.py
@@ -4,9 +4,8 @@ from sys import version_info
 from unittest import TestCase
 
 import pytest
-import sys
 
-from parsimonious.exceptions import BadGrammar, UndefinedLabel, ParseError, VisitationError
+from parsimonious.exceptions import BadGrammar, LeftRecursionError, ParseError, UndefinedLabel, VisitationError
 from parsimonious.expressions import Literal, Lookahead, Regex, Sequence, TokenMatcher, is_callable
 from parsimonious.grammar import rule_grammar, RuleVisitor, Grammar, TokenGrammar, LazyReference
 from parsimonious.nodes import Node
@@ -649,3 +648,20 @@ def test_inconsistent_string_types_in_grammar():
         foo = "foo"
         bar = "bar"
     """)
+
+
+def test_left_associative():
+    # Regression test for https://github.com/erikrose/parsimonious/issues/209
+    language_grammar = r"""
+    expression = operator_expression / non_operator_expression
+    non_operator_expression = number_expression
+
+    operator_expression = expression "+" non_operator_expression
+
+    number_expression = ~"[0-9]+"
+    """
+
+    grammar = Grammar(language_grammar)
+    with pytest.raises(LeftRecursionError) as e:
+        grammar["operator_expression"].parse("1+2")
+    assert "Parsimonious is a packrat parser, so it can't handle left recursion." in str(e.value)