Repository: neogeny/TatSu

Issue:
Calling `str()` on a model containing non-JSON-representable types raises exception
Having the `__str__` method of `tatsu.objectmodel.Node` returning a JSON representation is reasonable, however it it problematic for parsers that return an AST containing objects that are not supported by `json.dumps()` notably `datetime.datetime` and `datetime.date` object which I think are not that exotic.

Maybe a representation based on `pprint` of the same data structure could be used instead?

Minimal example demonstrating the issue:
```
import datetime
import tatsu
import tatsu.model
import textwrap

class Semantics(tatsu.model.ModelBuilderSemantics):
    def date(self, ast):
        return datetime.datetime.strptime(ast, '%Y-%m-%d').date()

parser = tatsu.compile(textwrap.dedent('''
    literal::Literal
        = value:date $
        ;
    date
        = /\d{4}-\d{2}-\d{2}/
        ;
    '''), semantics=Semantics())

ast = parser.parse('2022-05-29')
print(repr(ast))
print(ast)
```


Discussion:

Patch:
diff --git a/.pylintrc b/.pylintrc
index bb78ebb..1f1165a 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -165,7 +165,7 @@ disable=
     # c-extension-no-member,  # I1101
     # literal-comparison,  # R0123
     # comparison-with-itself,  # R0124
-    no-self-use,  # R0201
+    # no-self-use,  # R0201 / moved to extension
     # no-classmethod-decorator,  # R0202
     # no-staticmethod-decorator,  # R0203
     # useless-object-inheritance,  # R0205
diff --git a/docs/use.rst b/docs/use.rst
index 9ffd252..e79c870 100644
--- a/docs/use.rst
+++ b/docs/use.rst
@@ -245,7 +245,7 @@ passing the grammar to parse and the starting rule's name as parameter:
     from myparser import MyParser
 
     parser = MyParser()
-    ast = parser.parse('text to parse', rule_name='start')
+    ast = parser.parse('text to parse', start='start')
     print(ast)
     print(json.dumps(asjson(ast), indent=2))
 
@@ -258,7 +258,7 @@ method:
 
 .. code:: python
 
-    model = parser.parse(text, rule_name='start', semantics=MySemantics())
+    model = parser.parse(text, start='start', semantics=MySemantics())
 
 If special lexical treatment is required (as in *80 column* languages),
 then a descendant of ``tatsu.tokenizing.Tokenizer`` can be passed instead of
@@ -270,7 +270,7 @@ the text:
         ...
 
     tokenizer = MySpecialTokenizer(text)
-    model = parser.parse(tokenizer, rule_name='start', semantics=MySemantics())
+    model = parser.parse(tokenizer, start='start', semantics=MySemantics())
 
 The generated parser's module can also be invoked as a script:
 
diff --git a/examples/calc/calc.py b/examples/calc/calc.py
index d83c1bf..86c108b 100644
--- a/examples/calc/calc.py
+++ b/examples/calc/calc.py
@@ -1,6 +1,4 @@
 # -*- coding: utf-8 -*-
-from __future__ import generator_stop
-
 import json
 from pprint import pprint
 
diff --git a/examples/calc/codegen.py b/examples/calc/codegen.py
index 6e16c5d..9f329f6 100644
--- a/examples/calc/codegen.py
+++ b/examples/calc/codegen.py
@@ -1,6 +1,4 @@
 # -*- coding: utf-8 -*-
-from __future__ import generator_stop
-
 import sys
 
 from tatsu.codegen import ModelRenderer
diff --git a/tatsu/ast.py b/tatsu/ast.py
index 8eb0b68..3d624c3 100644
--- a/tatsu/ast.py
+++ b/tatsu/ast.py
@@ -29,7 +29,7 @@ class AST(dict):
         super().__setitem__('parseinfo', value)
 
     def copy(self):
-        return self.__copy__()
+        return self.__copy__()  # pylint: disable=unnecessary-dunder-call
 
     def asjson(self):
         return asjson(self)
diff --git a/tatsu/bootstrap.py b/tatsu/bootstrap.py
index 05bbab6..6fe0457 100644
--- a/tatsu/bootstrap.py
+++ b/tatsu/bootstrap.py
@@ -1434,7 +1434,7 @@ def main(filename, start=None, **kwargs):
     parser = EBNFBootstrapParser()
     return parser.parse(
         text,
-        rule_name=start,
+        start=start,
         filename=filename,
         **kwargs
     )
diff --git a/tatsu/codegen/python.py b/tatsu/codegen/python.py
index 7f5ebd9..f25a69c 100755
--- a/tatsu/codegen/python.py
+++ b/tatsu/codegen/python.py
@@ -562,6 +562,7 @@ class Grammar(Base):
                             namechars={namechars},
                             parseinfo={parseinfo},
                             keywords=KEYWORDS,
+                            start={start!r},
                         )
                         config = config.replace(**settings)
                         super().__init__(config=config)
@@ -573,9 +574,7 @@ class Grammar(Base):
                 {abstract_rules}
 
 
-                def main(filename, start=None, **kwargs):
-                    if start is None:
-                        start = '{start}'
+                def main(filename, **kwargs):
                     if not filename or filename == '-':
                         text = sys.stdin.read()
                     else:
@@ -584,7 +583,6 @@ class Grammar(Base):
                     parser = {name}Parser()
                     return parser.parse(
                         text,
-                        rule_name=start,
                         filename=filename,
                         **kwargs
                     )
diff --git a/tatsu/grammars.py b/tatsu/grammars.py
index cecb26b..96db87e 100644
--- a/tatsu/grammars.py
+++ b/tatsu/grammars.py
@@ -1084,7 +1084,7 @@ class Grammar(Model):
         start = config.effective_rule_name()
         if start is None:
             start = self.rules[0].name
-            config.start_rule = start
+            config.start_rule = start  # FIXME
 
         if ctx is None:
             ctx = ModelContext(self.rules, config=config)
diff --git a/tatsu/infos.py b/tatsu/infos.py
index 2060bf0..a25315d 100644
--- a/tatsu/infos.py
+++ b/tatsu/infos.py
@@ -28,6 +28,7 @@ class ParserConfig:
 
     start: str|None = None  # FIXME
     start_rule: str|None = None  # FIXME
+    rule_name: str|None = None  # Backward compatibility
 
     comments_re: str|None = COMMENTS_RE
     eol_comments_re: str|None = EOL_COMMENTS_RE
@@ -78,6 +79,7 @@ class ParserConfig:
         # note: there are legacy reasons for this mess
         return (
             self.start_rule or
+            self.rule_name or
             self.start
         )
 
diff --git a/tatsu/util/_common.py b/tatsu/util/_common.py
index 49e74ce..a77e2b0 100644
--- a/tatsu/util/_common.py
+++ b/tatsu/util/_common.py
@@ -293,8 +293,14 @@ def plainjson(obj):
         return obj
 
 
+class FallbackJSONEncoder(json.JSONEncoder):
+    """A JSON Encoder that falls back to repr() for non-JSON-aware objects."""
+    def default(self, obj):
+        return repr(obj)
+
+
 def asjsons(obj):
-    return json.dumps(asjson(obj), indent=2)
+    return json.dumps(asjson(obj), indent=2, cls=FallbackJSONEncoder)
 
 
 def prune_dict(d, predicate):


Test patch:
diff --git a/test/__init__.py b/test/__init__.py
index ccdd1d1..e69de29 100644
--- a/test/__init__.py
+++ b/test/__init__.py
@@ -1,2 +0,0 @@
-# -*- coding: utf-8 -*-
-from __future__ import generator_stop
diff --git a/test/__main__.py b/test/__main__.py
index 273944e..a329e21 100644
--- a/test/__main__.py
+++ b/test/__main__.py
@@ -1,6 +1,4 @@
 # -*- coding: utf-8 -*-
-from __future__ import generator_stop
-
 
 def main():
     import pytest
diff --git a/test/ast_test.py b/test/ast_test.py
index e55737f..0f39cef 100644
--- a/test/ast_test.py
+++ b/test/ast_test.py
@@ -1,6 +1,4 @@
 # -*- coding: utf-8 -*-
-from __future__ import generator_stop
-
 import unittest
 
 from tatsu.ast import AST
diff --git a/test/buffering_test.py b/test/buffering_test.py
index 66433a6..968ef41 100644
--- a/test/buffering_test.py
+++ b/test/buffering_test.py
@@ -3,8 +3,6 @@
 Tests for consistency of the line information caches kept by
 tatsu.buffering.Buffer.
 """
-from __future__ import generator_stop
-
 import os
 import random
 import unittest
diff --git a/test/codegen_test.py b/test/codegen_test.py
index f7f0ed3..f994685 100644
--- a/test/codegen_test.py
+++ b/test/codegen_test.py
@@ -1,6 +1,4 @@
 # -*- coding: utf-8 -*-
-from __future__ import generator_stop
-
 import unittest
 
 from tatsu.codegen import CodeGenerator, ModelRenderer
diff --git a/test/diagram_test.py b/test/diagram_test.py
index 67f3eb1..13b801f 100644
--- a/test/diagram_test.py
+++ b/test/diagram_test.py
@@ -1,6 +1,4 @@
 # -*- coding: utf-8 -*-
-from __future__ import generator_stop
-
 import sys
 import unittest
 import pytest
diff --git a/test/grammar/directive_test.py b/test/grammar/directive_test.py
index df19b9a..0f4ea96 100644
--- a/test/grammar/directive_test.py
+++ b/test/grammar/directive_test.py
@@ -1,6 +1,4 @@
 # -*- coding: utf-8 -*-
-from __future__ import generator_stop
-
 import unittest
 
 import tatsu
diff --git a/test/grammar/firstfollow_test.py b/test/grammar/firstfollow_test.py
index 810ec32..1c93c91 100644
--- a/test/grammar/firstfollow_test.py
+++ b/test/grammar/firstfollow_test.py
@@ -1,6 +1,4 @@
 # -*- coding: utf-8 -*-
-from __future__ import generator_stop
-
 import unittest
 
 from tatsu.tool import compile
diff --git a/test/grammar/join_test.py b/test/grammar/join_test.py
index 6966131..0290ee5 100644
--- a/test/grammar/join_test.py
+++ b/test/grammar/join_test.py
@@ -1,5 +1,3 @@
-from __future__ import generator_stop
-
 import unittest
 from ast import parse
 
diff --git a/test/grammar/keyword_test.py b/test/grammar/keyword_test.py
index 41106fa..86ae1aa 100644
--- a/test/grammar/keyword_test.py
+++ b/test/grammar/keyword_test.py
@@ -1,6 +1,4 @@
 # -*- coding: utf-8 -*-
-from __future__ import generator_stop
-
 import unittest
 from ast import parse
 
diff --git a/test/grammar/left_recursion_test.py b/test/grammar/left_recursion_test.py
index b100ff0..a7fe431 100644
--- a/test/grammar/left_recursion_test.py
+++ b/test/grammar/left_recursion_test.py
@@ -1,6 +1,4 @@
 # -*- coding: utf-8 -*-
-from __future__ import generator_stop
-
 import unittest
 
 from tatsu.exceptions import FailedParse
diff --git a/test/grammar/lookahead_test.py b/test/grammar/lookahead_test.py
index 7ea673f..5b5667c 100644
--- a/test/grammar/lookahead_test.py
+++ b/test/grammar/lookahead_test.py
@@ -1,6 +1,4 @@
 # -*- coding: utf-8 -*-
-from __future__ import generator_stop
-
 import unittest
 
 from tatsu.tool import compile
diff --git a/test/grammar/parameter_test.py b/test/grammar/parameter_test.py
index 001334d..4dabd71 100644
--- a/test/grammar/parameter_test.py
+++ b/test/grammar/parameter_test.py
@@ -1,6 +1,4 @@
 # -*- coding: utf-8 -*-
-from __future__ import generator_stop
-
 import unittest
 
 from tatsu.parser import GrammarGenerator
diff --git a/test/grammar/pattern_test.py b/test/grammar/pattern_test.py
index d32f41b..d734203 100644
--- a/test/grammar/pattern_test.py
+++ b/test/grammar/pattern_test.py
@@ -1,6 +1,4 @@
 # -*- coding: utf-8 -*-
-from __future__ import generator_stop
-
 import unittest
 
 from tatsu.util import trim
diff --git a/test/grammar/pretty_test.py b/test/grammar/pretty_test.py
index 2848c5b..39ecdb0 100644
--- a/test/grammar/pretty_test.py
+++ b/test/grammar/pretty_test.py
@@ -1,6 +1,4 @@
 # -*- coding: utf-8 -*-
-from __future__ import generator_stop
-
 import unittest
 
 from tatsu.util import trim
diff --git a/test/grammar/semantics_test.py b/test/grammar/semantics_test.py
index a742b6a..fd35d5a 100644
--- a/test/grammar/semantics_test.py
+++ b/test/grammar/semantics_test.py
@@ -1,6 +1,4 @@
 # -*- coding: utf-8 -*-
-from __future__ import generator_stop
-
 import unittest
 from typing import Any
 
diff --git a/test/grammar/stateful_test.py b/test/grammar/stateful_test.py
index 56dc791..0e66c18 100644
--- a/test/grammar/stateful_test.py
+++ b/test/grammar/stateful_test.py
@@ -1,6 +1,4 @@
 # -*- coding: utf-8 -*-
-from __future__ import generator_stop
-
 import unittest
 
 from tatsu.exceptions import FailedSemantics
diff --git a/test/grammar/syntax_test.py b/test/grammar/syntax_test.py
index 7c4a9d7..34490f3 100644
--- a/test/grammar/syntax_test.py
+++ b/test/grammar/syntax_test.py
@@ -1,6 +1,4 @@
 # -*- coding: utf-8 -*-
-from __future__ import generator_stop
-
 import unittest
 
 from tatsu.exceptions import FailedParse
diff --git a/test/model_test.py b/test/model_test.py
index 1bb2e4e..e128483 100644
--- a/test/model_test.py
+++ b/test/model_test.py
@@ -1,5 +1,3 @@
-from __future__ import generator_stop
-
 from tatsu.objectmodel import Node
 
 
diff --git a/test/parser_equivalence_test.py b/test/parser_equivalence_test.py
index 072a91e..5f64116 100644
--- a/test/parser_equivalence_test.py
+++ b/test/parser_equivalence_test.py
@@ -1,11 +1,9 @@
-
-import importlib
-from pathlib import Path
+import types
 
 import pytest  # noqa
 
+import tatsu
 from tatsu.exceptions import FailedParse
-from tatsu.tool import compile, gencode
 
 INPUT = """
     1d3
@@ -42,37 +40,19 @@ GRAMMAR = """
 
 
 def generate_and_load_parser(name, grammar):
-    init_filename = Path('./tmp/__init__')
-    init_filename.touch(exist_ok=True)
-
-    parser = gencode(name='Test', grammar=grammar)
-    parser_filename = Path(f'./tmp/{name}.py')
-    with open(parser_filename, 'wt') as f:
-        f.write(parser)
-    try:
-        importlib.invalidate_caches()
-        module = importlib.import_module(f'tmp.{name}', 'tmp')
-        importlib.reload(module)
-        try:
-            return module.UnknownParser()  # noqa
-        except (AttributeError, ImportError):
-            return module.TestParser()  # noqa
-    finally:
-        pass
-        # parser_filename.unlink()
+    code = tatsu.to_python_sourcecode(grammar, name='Test')
+    module = types.ModuleType(name)
+    module.__file__ = '<generated>'
+    exec(compile(code, module.__file__, 'exec'), module.__dict__)
+    return module.TestParser()  # noqa
 
 
 def test_model_parse():
-    model = compile(name='Test', grammar=GRAMMAR)
+    model = tatsu.compile(name='Test', grammar=GRAMMAR)
     assert OUTPUT == model.parse(INPUT)
 
 
 def test_codegen_parse():
-    tmp_dir = Path('./tmp')
-    tmp_dir.mkdir(parents=True, exist_ok=True)
-    init_filename = Path('./tmp/__init__.py')
-    init_filename.touch(exist_ok=True)
-
     parser = generate_and_load_parser('test_codegen_parse', GRAMMAR)
     output = parser.parse(INPUT, parseinfo=False)
     assert output == OUTPUT
@@ -91,7 +71,7 @@ def test_error_messages():
     input = 'a b'
 
     e1 = None
-    model = compile(grammar)
+    model = tatsu.compile(grammar)
     try:
         model.parse(input)
     except FailedParse as e:  # noqa
@@ -120,7 +100,22 @@ def test_name_checked():
         with pytest.raises(FailedParse):
             parser.parse('IF if 1', trace=False)
 
-    parser = compile(grammar, 'Test')
+    parser = tatsu.compile(grammar, 'Test')
     subtest(parser)
     parser = generate_and_load_parser('test_name_checked', grammar)
     subtest(parser)
+
+
+def test_first_rule():
+    grammar = '''
+        @@grammar :: Test
+
+        true = 'test' @: `True` $ ;
+        start = 'test' @: `False` $ ;
+    '''
+    parser = compile(grammar, 'Test')
+    ast = parser.parse('test')
+    assert ast is True
+    parser = generate_and_load_parser('test_first_rule', grammar)
+    ast = parser.parse('test')
+    assert ast is True
diff --git a/test/parsing_test.py b/test/parsing_test.py
index cbca5dd..70b79e3 100644
--- a/test/parsing_test.py
+++ b/test/parsing_test.py
@@ -1,6 +1,4 @@
 # -*- coding: utf-8 -*-
-from __future__ import generator_stop
-
 import unittest
 import tempfile
 
@@ -59,22 +57,35 @@ class ParsingTests(unittest.TestCase):
         self.assertEqual('\nañez', eval_escapes(r'\na\xf1ez'))
         self.assertEqual('\nañez', eval_escapes(r'\nañez'))
 
-    def test_rule_name(self):
+    def test_start(self):
         grammar = '''
             @@grammar :: Test
 
-            start = test $;
-            test = "test";
+            true = "test" @:`True` $;
+            false = "test" @:`False` $;
+
         '''
         model = tatsu.compile(grammar=grammar)
         self.assertEqual('Test', model.directives.get('grammar'))
         self.assertEqual('Test', model.name)
 
+        # By default, parsing starts from the first rule in the grammar.
         ast = model.parse("test")
-        self.assertEqual(ast, "test")
-
-        ast = tatsu.parse(grammar, "test", rule_name='start')
-        self.assertEqual(ast, "test")
+        self.assertEqual(ast, True)
+
+        # The start rule can be passed explicitly.
+        ast = model.parse("test", start='true')
+        self.assertEqual(ast, True)
+        # Backward compatibility argument name.
+        ast = model.parse("test", rule_name='true')
+        self.assertEqual(ast, True)
+
+        # The default rule can be overwritten.
+        ast = tatsu.parse(grammar, "test", start='false')
+        self.assertEqual(ast, False)
+        # Backward compatibility argument name.
+        ast = tatsu.parse(grammar, "test", rule_name='false')
+        self.assertEqual(ast, False)
 
     def test_rule_capitalization(self):
         grammar = '''
@@ -84,13 +95,13 @@ class ParsingTests(unittest.TestCase):
         test_string = 'test 12'
         lowercase_rule_names = ['nocaps', 'camelCase', 'tEST']
         uppercase_rule_names = ['Capitalized', 'CamelCase', 'TEST']
-        ref_lowercase_result = tatsu.parse(grammar.format(rulename='reflowercase'), test_string, rule_name='start')
-        ref_uppercase_result = tatsu.parse(grammar.format(rulename='Refuppercase'), test_string, rule_name='start')
+        ref_lowercase_result = tatsu.parse(grammar.format(rulename='reflowercase'), test_string)
+        ref_uppercase_result = tatsu.parse(grammar.format(rulename='Refuppercase'), test_string)
         for rulename in lowercase_rule_names:
-            result = tatsu.parse(grammar.format(rulename=rulename), test_string, rule_name='start')
+            result = tatsu.parse(grammar.format(rulename=rulename), test_string)
             self.assertEqual(result, ref_lowercase_result)
         for rulename in uppercase_rule_names:
-            result = tatsu.parse(grammar.format(rulename=rulename), test_string, rule_name='start')
+            result = tatsu.parse(grammar.format(rulename=rulename), test_string)
             self.assertEqual(result, ref_uppercase_result)
 
     def test_startrule_issue62(self):
diff --git a/test/walker_test.py b/test/walker_test.py
index 8b7a108..cdcc022 100644
--- a/test/walker_test.py
+++ b/test/walker_test.py
@@ -1,5 +1,3 @@
-from __future__ import generator_stop
-
 import json
 from collections import defaultdict