Repository: neogeny/TatSu

Issue:
Get rid of long parameter lists
Currently parser configuration is done through very long parameter lists in `grammar`, `contexts`, and `buffering`, with also many lines of code dedicated to merging or overriding the configuration.

As it is, it's very difficult to know what the active configuration is on each module or parser class.

Move all parser configuration settings into a `ParserConfiguration` object with `.merge(other)` and `.override(other` methods.

Discussion:
apalala: This is done, @Victorious3.

Patch:
diff --git a/.pylintrc b/.pylintrc
index 0fd8660..9d0055d 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -157,7 +157,9 @@ disable=C0102, C0103, C0111, C0122, C0123,
         R0912, R0913, R0914, R0915,
         R1702, R1705, R1710, R1711,
         W0101, W0108,
-        W0201, W0212, W0223,
+        W0201, W0212,
+        W0221, # Parameters differ from overridden 'parse' method (arguments-differ)
+        W0223,
         W0401,
         W0511,
         W0603, W0612, W0611, W0613,
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 66e0a1f..1ffced3 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -14,6 +14,9 @@ The format of this *Change Log* is inspired by `keeapachangelog.org`_.
 ---------------
 .. _`X.Y.Z`: https://github.com/apalala/tatsu/compare/v5.6.1...master
 
+*   Parser configuration variables were moved into a ``config: ParserConfig`` attribute.This may impact code that accessed those variables directly. Now instead of ``model.configuration_var`` you should use ``model.config.configuration_var``
+*   Now ``config: ParserConfig`` is used in ``__init__()`` and ``parse()`` methods of ``contexts.ParseContext``, ``grammars.Grammar``, and elsewhere to avoid the very long parameter lists that abounded. ``ParseContext`` also provides clean and clear ways of overridinga group of settings with another.
+
 
 `5.6.1`_ @ 2021-03-22
 ---------------------
diff --git a/mypy.ini b/mypy.ini
index 922e5b2..0dacb26 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -1,4 +1,4 @@
 [mypy]
 python_version = 3.9
 ignore_missing_imports = True
-exclude = parsers|docs
+exclude = parsers|docs|build|tmp
diff --git a/tatsu/bootstrap.py b/tatsu/bootstrap.py
index f34d885..a13de4d 100644
--- a/tatsu/bootstrap.py
+++ b/tatsu/bootstrap.py
@@ -1325,7 +1325,7 @@ def main(filename, start=None, **kwargs):
         with open(filename) as f:
             text = f.read()
     parser = EBNFBootstrapParser()
-    return parser.parse(text, rule_name=start, filename=filename, **kwargs)
+    return parser.parse(text, start=start, filename=filename, **kwargs)
 
 
 if __name__ == '__main__':
diff --git a/tatsu/buffering.py b/tatsu/buffering.py
index 74b7af7..44442b3 100644
--- a/tatsu/buffering.py
+++ b/tatsu/buffering.py
@@ -9,6 +9,7 @@ from __future__ import annotations
 
 import os
 from itertools import takewhile, repeat
+from typing import (Any,)
 
 from .tokenizing import Tokenizer
 from .util import identity
@@ -16,69 +17,52 @@ from .util import extend_list, contains_sublist
 from .util import re as regexp
 from .util import RETYPE, WHITESPACE_RE
 from .exceptions import ParseError
-from .infos import PosLine, LineIndexInfo, LineInfo, CommentInfo
+from .infos import (
+    ParserConfig,
+    PosLine, LineIndexInfo, LineInfo, CommentInfo,
+)
 
 # for backwards compatibility with existing parsers
 LineIndexEntry = LineIndexInfo
 
 
 class Buffer(Tokenizer):
-    def __init__(self,
-                 text,
-                 filename=None,
-                 whitespace=None,
-                 comments_re=None,
-                 eol_comments_re=None,
-                 ignorecase=False,
-                 nameguard=None,
-                 comment_recovery=False,
-                 namechars='',
-                 **kwargs):
+    def __init__(self, text, /, config: ParserConfig = None, **settings: Any):
+        config = ParserConfig.new(config=config, owner=self, **settings)
+        self.config = config
+
         text = str(text)
         self.text = self.original_text = text
-        self._filename = filename or ''
-
-        self.whitespace = whitespace
 
-        self.comments_re = comments_re
-        self.eol_comments_re = eol_comments_re
-        self._ignorecase = ignorecase
+        self.whitespace_re = self.build_whitespace_re(config.whitespace)
         self.nameguard = (
-            nameguard if nameguard is not None
-            else bool(self.whitespace_re) or bool(namechars)
+            config.nameguard if config.nameguard is not None
+            else bool(self.whitespace_re) or bool(config.namechars)
         )
-        self.comment_recovery = comment_recovery
-        self.namechars = namechars if namechars is not None else ''
-        self._namechar_set = set(self.namechars)
+        self._namechar_set = set(config.namechars)
 
         self._pos = 0
         self._len = 0
         self._linecount = 0
-        self._lines = []
-        self._line_index = []
-        self._line_cache = []
-        self._comment_index = []
-        self._re_cache = {}
+        self._lines: list[str] = []
+        self._line_index: list[LineIndexInfo] = []
+        self._line_cache: list[PosLine] = []
+        self._comment_index: list[CommentInfo] = []
 
         self._preprocess()
         self._postprocess()
 
     @property
     def filename(self):
-        return self._filename
+        return self.config.filename
 
     @property
     def ignorecase(self):
-        return self._ignorecase
+        return self.config.ignorecase
 
     @property
     def whitespace(self):
-        return self._whitespace
-
-    @whitespace.setter
-    def whitespace(self, value):
-        self._whitespace = value
-        self.whitespace_re = self.build_whitespace_re(value)
+        return self.config.whitespace
 
     @staticmethod
     def build_whitespace_re(whitespace):
@@ -219,7 +203,7 @@ class Buffer(Tokenizer):
         self.goto(self.pos + n)
 
     def comments(self, p, clear=False):
-        if not self.comment_recovery or not self._comment_index:
+        if not self.config.comment_recovery or not self._comment_index:
             return CommentInfo([], [])
 
         n = self.posline(p)
@@ -242,7 +226,7 @@ class Buffer(Tokenizer):
         return CommentInfo(cmm, eolcmm)
 
     def _index_comments(self, comments, selector):
-        if comments and self.comment_recovery:
+        if comments and self.config.comment_recovery:
             n = self.line
             extend_list(self._comment_index, n, default=CommentInfo.new_comment)
             previous = selector(self._comment_index[n])
@@ -257,11 +241,11 @@ class Buffer(Tokenizer):
         return self._eat_regex(self.whitespace_re)
 
     def eat_comments(self):
-        comments = self._eat_regex(self.comments_re)
+        comments = self._eat_regex(self.config.comments_re)
         self._index_comments(comments, lambda x: x.inline)
 
     def eat_eol_comments(self):
-        comments = self._eat_regex(self.eol_comments_re)
+        comments = self._eat_regex(self.config.eol_comments_re)
         self._index_comments(comments, lambda x: x.eol)
 
     def next_token(self):
@@ -334,11 +318,8 @@ class Buffer(Tokenizer):
     def _scanre(self, pattern, ignorecase=None, offset=0):
         if isinstance(pattern, RETYPE):
             re = pattern
-        elif pattern in self._re_cache:
-            re = self._re_cache[pattern]
         else:
             re = regexp.compile(pattern, regexp.MULTILINE | regexp.UNICODE)
-            self._re_cache[pattern] = re
         return re.match(self.text, self.pos + offset)
 
     @property
diff --git a/tatsu/codegen/python.py b/tatsu/codegen/python.py
index 0094202..eae998a 100644
--- a/tatsu/codegen/python.py
+++ b/tatsu/codegen/python.py
@@ -429,7 +429,7 @@ class Grammar(Base):
         ]
         abstract_rules = indent('\n'.join(abstract_rules))
 
-        whitespace = self.node.whitespace or self.node.directives.get('whitespace')
+        whitespace = self.node.config.whitespace
         if not whitespace:
             whitespace = 'None'
         elif isinstance(whitespace, RETYPE):
@@ -437,20 +437,17 @@ class Grammar(Base):
         else:
             whitespace = 're.compile({0})'.format(repr(whitespace))
 
-        if self.node.nameguard is not None:
-            nameguard = repr(self.node.nameguard)
-        elif self.node.directives.get('nameguard') is not None:
-            nameguard = self.node.directives.get('nameguard')
+        if self.node.config.nameguard is not None:
+            nameguard = repr(self.node.config.nameguard)
         else:
             nameguard = 'None'
 
-        comments_re = repr(self.node.directives.get('comments'))
-        eol_comments_re = repr(self.node.directives.get('eol_comments'))
-        ignorecase = self.node.directives.get('ignorecase', 'None')
-        left_recursion = self.node.directives.get('left_recursion', True)
-        parseinfo = self.node.directives.get('parseinfo', True)
-
-        namechars = repr(self.node.directives.get('namechars') or '')
+        comments_re = repr(self.node.config.comments)
+        eol_comments_re = repr(self.node.config.eol_comments)
+        ignorecase = self.node.config.ignorecase
+        left_recursion = self.node.config.left_recursion
+        parseinfo = self.node.config.parseinfo
+        namechars = repr(self.node.config.namechars or '')
 
         rules = '\n'.join([
             self.get_renderer(rule).render() for rule in self.node.rules
diff --git a/tatsu/contexts.py b/tatsu/contexts.py
index f083874..575ff4e 100644
--- a/tatsu/contexts.py
+++ b/tatsu/contexts.py
@@ -6,7 +6,6 @@ from contextlib import contextmanager
 from copy import copy
 
 from tatsu.util.unicode_characters import (
-    C_DERIVE,
     C_ENTRY,
     C_SUCCESS,
     C_FAILURE,
@@ -16,7 +15,8 @@ from . import tokenizing
 from . import buffering
 from . import color
 from .ast import AST
-from .util import notnone, prune_dict, is_list, info, safe_name
+from .collections import OrderedSet as oset
+from .util import prune_dict, is_list, info, safe_name
 from .util import left_assoc, right_assoc
 from .infos import (
     MemoKey,
@@ -24,6 +24,7 @@ from .infos import (
     RuleInfo,
     RuleResult,
     ParseState,
+    ParserConfig,
 )
 from tatsu.exceptions import (  # noqa
     FailedCut,
@@ -37,7 +38,8 @@ from tatsu.exceptions import (  # noqa
     FailedKeyword,
     FailedKeywordSemantics,
     FailedToken,
-    OptionSucceeded
+    OptionSucceeded,
+    ParseError,
 )
 
 __all__ = ['ParseContext', 'tatsumasu', 'leftrec', 'nomemo']
@@ -94,48 +96,18 @@ class closure(list):
 
 
 class ParseContext(object):
-    def __init__(self,
-                 tokenizercls=buffering.Buffer,
-                 semantics=None,
-                 parseinfo=False,
-                 trace=False,
-                 encoding='utf-8',
-                 comments_re=None,
-                 eol_comments_re=None,
-                 whitespace=None,
-                 ignorecase=False,
-                 nameguard=None,
-                 memoize_lookaheads=True,
-                 left_recursion=True,
-                 trace_length=72,
-                 trace_separator=C_DERIVE,
-                 trace_filename=False,
-                 colorize=None,
-                 keywords=None,
-                 namechars='',
-                 **kwargs):
+    def __init__(self, config: ParserConfig = None, tokenizer=None, tokenizercls=None, **settings):
         super().__init__()
+        config = ParserConfig.new(config, **settings)
+        config.replace(tokenizercls=tokenizercls)
+        self.config = config
 
-        self._tokenizer = None
+        if tokenizercls is None:
+            tokenizercls = buffering.Buffer
         self.tokenizercls = tokenizercls
-        self.semantics = semantics
-        self.encoding = encoding
-        self.parseinfo = parseinfo
-        self.trace = trace
-        self.trace_length = trace_length
-        self.trace_separator = trace_separator
-        self.trace_filename = trace_filename
-
-        self.comments_re = comments_re
-        self.eol_comments_re = eol_comments_re
-        self.whitespace = whitespace
-        self.ignorecase = ignorecase
-        self.nameguard = nameguard
-        self.memoize_lookaheads = memoize_lookaheads
-        self.left_recursion = left_recursion
-        self.colorize = colorize
-        self.keywords = set(keywords or [])
-        self.namechars = namechars
+        self._tokenizer = tokenizer
+
+        self._semantics = config.semantics
 
         self._initialize_caches()
 
@@ -147,96 +119,110 @@ class ParseContext(object):
         self._last_node = None
         self.substate = None
         self._lookahead = 0
+        self._furthest_exception = None
 
         self._clear_memoization_caches()
 
-    def _reset(self,
-               text=None,
-               filename=None,
-               tokenizercls=None,
-               semantics=None,
-               trace=None,
-               comments_re=None,
-               eol_comments_re=None,
-               whitespace=None,
-               ignorecase=None,
-               nameguard=None,
-               memoize_lookaheads=None,
-               left_recursion=None,
-               colorize=None,
-               keywords=None,
-               namechars=None,
-               **kwargs):
-        if ignorecase is None:
-            ignorecase = self.ignorecase
-        if nameguard is None:
-            nameguard = self.nameguard
-        if namechars is None:
-            namechars = self.namechars
-        if memoize_lookaheads is not None:
-            self.memoize_lookaheads = memoize_lookaheads
-        if left_recursion is not None:
-            self.left_recursion = left_recursion
-        if trace is not None:
-            self.trace = trace
-        if semantics is not None:
-            self.semantics = semantics
-        if colorize is not None:
-            self.colorize = colorize
-        if keywords is not None:
-            self.keywords = keywords
-        if self.colorize:
-            color.init()
+    @property
+    def semantics(self):
+        return self._semantics
 
-        self._initialize_caches()
-        self._furthest_exception = None
+    @property
+    def encoding(self):
+        return self.config.encoding
 
-        if isinstance(text, tokenizing.Tokenizer):
-            tokenizer = text
-        else:
-            tokenizercls = tokenizercls or self.tokenizercls
-            tokenizer = tokenizercls(
-                text,
-                filename=filename,
-                comments_re=comments_re or self.comments_re,
-                eol_comments_re=eol_comments_re or self.eol_comments_re,
-                whitespace=notnone(whitespace, default=self.whitespace),
-                ignorecase=ignorecase,
-                nameguard=nameguard,
-                namechars=namechars,
-                **kwargs)
-        self._tokenizer = tokenizer
+    @property
+    def parseinfo(self):
+        return self.config.parseinfo
+
+    @property
+    def trace(self):
+        return self.config.trace
+
+    @property
+    def trace_length(self):
+        return self.config.trace_length
+
+    @property
+    def trace_separator(self):
+        return self.config.trace_separator
+
+    @property
+    def trace_filename(self):
+        return self.config.trace_filename
+
+    @property
+    def comments_re(self):
+        return self.config.comments_re
+
+    @property
+    def eol_comments_re(self):
+        return self.config.eol_comments_re
 
+    @property
+    def whitespace(self):
+        return self.config.whitespace
+
+    @property
+    def ignorecase(self):
+        return self.config.ignorecase
+
+    @property
+    def nameguard(self):
+        return self.config.nameguard
+
+    @property
+    def memoize_lookaheads(self):
+        return self.config.memoize_lookaheads
+
+    @property
+    def left_recursion(self):
+        return self.config.left_recursion
+
+    @property
+    def colorize(self):
+        return self.config.colorize
+
+    @property
+    def keywords(self):
+        return self._keywords
+
+    @property
+    def namechars(self):
+        return self.config.namechars
+
+    def _reset(self, config: ParserConfig):
+        if self.config.colorize:
+            color.init()
+        self._initialize_caches()
+        self._keywords = oset(config.keywords)
+        self._semantics = config.semantics
         if hasattr(self.semantics, 'set_context'):
             self.semantics.set_context(self)
+        return config
 
     def _set_furthest_exception(self, e):
         if not self._furthest_exception or e.pos > self._furthest_exception.pos:
             self._furthest_exception = e
 
-    def parse(self,
-              text,
-              rule_name='start',
-              filename=None,
-              tokenizercls=None,
-              semantics=None,
-              trace=False,
-              whitespace=None,
-              ignorecase=None,
-              **kwargs):
+    def parse(self, text, /, start='start', config: ParserConfig = None, tokenizercls=None, **settings):
+        config = self.config.replace_config(config)
+        config = config.replace(**settings)
+        config = config.replace(start=start)
+
+        self._reset(config)
+        if isinstance(text, tokenizing.Tokenizer):
+            tokenizer = text
+        elif text is not None:
+            tokenizercls = tokenizercls or self.tokenizercls
+            tokenizer = tokenizercls(text, config=config)
+        else:
+            raise ParseError('No tokenizer or text')
+        self._tokenizer = tokenizer
+        start = start or self.config.effective_rule_name()
+
         try:
-            self.parseinfo = kwargs.pop('parseinfo', self.parseinfo)
-            self._reset(
-                text=text,
-                filename=filename,
-                tokenizercls=tokenizercls,
-                semantics=semantics,
-                trace=trace if trace is not None else self.trace,
-                whitespace=whitespace if whitespace is not None else self.whitespace,
-                ignorecase=ignorecase,
-                **kwargs
-            )
-            rule = self._find_rule(rule_name)
+            rule = self._find_rule(start)
             result = rule()
             return result
         except FailedCut as e:
@@ -513,7 +499,7 @@ class ParseContext(object):
 
     def _make_exception(self, item, exclass=FailedParse):
         rulestack = list(map(lambda r: r.name, self._rule_stack))
-        return exclass(self._tokenizer, rulestack, item)
+        return exclass(self.tokenizer, rulestack, item)
 
     def _error(self, item, exclass=FailedParse):
         raise self._make_exception(item, exclass=exclass)
@@ -524,12 +510,12 @@ class ParseContext(object):
     def _get_parseinfo(self, name, pos):
         endpos = self._pos
         return ParseInfo(
-            self._tokenizer,
+            self.tokenizer,
             name,
             pos,
             endpos,
-            self._tokenizer.posline(pos),
-            self._tokenizer.posline(endpos),
+            self.tokenizer.posline(pos),
+            self.tokenizer.posline(endpos),
         )
 
     @property
@@ -692,7 +678,7 @@ class ParseContext(object):
 
     def _token(self, token):
         self._next_token()
-        if self._tokenizer.match(token) is None:
+        if self.tokenizer.match(token) is None:
             self._trace_match(token, failed=True)
             self._error(token, exclass=FailedToken)
         self._trace_match(token)
@@ -706,7 +692,7 @@ class ParseContext(object):
         return literal
 
     def _pattern(self, pattern):
-        token = self._tokenizer.matchre(pattern)
+        token = self.tokenizer.matchre(pattern)
         if token is None:
             self._trace_match('', pattern, failed=True)
             self._error(pattern, exclass=FailedPattern)
@@ -715,14 +701,14 @@ class ParseContext(object):
         return token
 
     def _eof(self):
-        return self._tokenizer.atend()
+        return self.tokenizer.atend()
 
     def _eol(self):
-        return self._tokenizer.ateol()
+        return self.tokenizer.ateol()
 
     def _check_eof(self):
         self._next_token()
-        if not self._tokenizer.atend():
+        if not self.tokenizer.atend():
             self._error('Expecting end of text', exclass=FailedExpectingEndOfText)
 
     @contextmanager
diff --git a/tatsu/grammars.py b/tatsu/grammars.py
index b6f4603..1bc1827 100644
--- a/tatsu/grammars.py
+++ b/tatsu/grammars.py
@@ -1,22 +1,22 @@
 from __future__ import annotations
 
-import os
 import functools
 from collections.abc import Mapping
 from collections import defaultdict
+from pathlib import Path
 from copy import copy
 from itertools import takewhile
 
 from tatsu.util import (
     indent, trim, compress_seq, chunks,
-    re, notnone,
+    re,
 )
 from .exceptions import FailedRef, GrammarError
 from .ast import AST
 from .contexts import ParseContext
 from .objectmodel import Node
 from .bootstrap import EBNFBootstrapBuffer
-from .infos import RuleInfo
+from .infos import RuleInfo, ParserConfig
 from .leftrec import Nullable, find_left_recursion
 from .collections import OrderedSet as oset
 
@@ -24,8 +24,6 @@ from .collections import OrderedSet as oset
 PEP8_LLEN = 72
 
 
-COMMENTS_RE = r'\(\*((?:.|\n)*?)\*\)'
-EOL_COMMENTS_RE = r'#([^\n]*?)$'
 PRAGMA_RE = r'^\s*#include.*$'
 
 
@@ -59,8 +57,8 @@ class EBNFBuffer(EBNFBootstrapBuffer):
             filename=filename,
             memoize_lookaheads=False,
             comment_recovery=True,
-            comments_re=comments_re or COMMENTS_RE,
-            eol_comments_re=eol_comments_re or EOL_COMMENTS_RE,
+            comments_re=comments_re,
+            eol_comments_re=eol_comments_re,
             **kwargs
         )
 
@@ -88,12 +86,12 @@ class EBNFBuffer(EBNFBootstrapBuffer):
 
 
 class ModelContext(ParseContext):
-    def __init__(self, rules, semantics=None, trace=False, **kwargs):
-        super().__init__(
-            semantics=semantics,
-            trace=trace,
-            **kwargs
-        )
+    def __init__(self, rules, /, start=None, config: ParserConfig = None, **settings):
+        config = ParserConfig.new(config, **settings)
+        config = config.replace(start=start)
+
+        super().__init__(config=config)
+
         self.rules = {rule.name: rule for rule in rules}
 
     @property
@@ -956,74 +954,27 @@ class BasedRule(Rule):
 
 
 class Grammar(Model):
-    def __init__(self,
-                 name,
-                 rules,
-                 semantics=None,
-                 filename='Unknown',
-                 whitespace=None,
-                 ignorecase=None,
-                 nameguard=None,
-                 namechars=None,
-                 left_recursion=None,
-                 comments_re=None,
-                 eol_comments_re=None,
-                 directives=None,
-                 parseinfo=None,
-                 keywords=None):
+    def __init__(self, name, rules, /, config: ParserConfig = None, directives: dict = None, **settings):
         super().__init__()
         assert isinstance(rules, list), str(rules)
+        directives = directives or {}
+        self.directives = directives
+
+        config = ParserConfig.new(config=config, owner=self, **directives)
+        config = config.replace(**settings)
+        self.config = config
 
         self.rules = rules
         self.rulemap = {rule.name: rule for rule in rules}
 
-        directives = directives or {}
-        self.directives = directives
+        config = config.merge(**directives)
 
         if name is None:
             name = self.directives.get('grammar')
         if name is None:
-            name = os.path.splitext(os.path.basename(filename))[0]
+            name = Path(config.filename).stem
         self.name = name
 
-        self.semantics = semantics
-
-        if whitespace is None:
-            whitespace = directives.get('whitespace')
-        self.whitespace = whitespace
-
-        if ignorecase is None:
-            ignorecase = directives.get('ignorecase')
-        self.ignorecase = ignorecase
-
-        if nameguard is None:
-            nameguard = directives.get('nameguard')
-        self.nameguard = nameguard
-
-        if namechars is None:
-            namechars = directives.get('namechars')
-        self.namechars = namechars
-
-        if left_recursion is None:
-            left_recursion = directives.get('left_recursion', True)
-        self.left_recursion = left_recursion
-
-        if parseinfo is None:
-            parseinfo = directives.get('parseinfo')
-        self._use_parseinfo = parseinfo
-
-        if comments_re is None:
-            comments_re = directives.get('comments')
-        self.comments_re = comments_re
-
-        if eol_comments_re is None:
-            eol_comments_re = directives.get('eol_comments')
-        self.eol_comments_re = eol_comments_re
-
-        self.keywords = oset(keywords)
-        if ignorecase:
-            self.keywords = oset(k.upper() for k in self.keywords)
-
         self._adopt_children(rules)
 
         missing = self.missing_rules({r.name for r in self.rules})
@@ -1032,9 +983,21 @@ class Grammar(Model):
             raise GrammarError('Unknown rules, no parser generated:' + msg)
 
         self._calc_lookahead_sets()
-        if left_recursion:
+        if config.left_recursion:
             find_left_recursion(self)
 
+    @property
+    def keywords(self):
+        return self.config.keywords
+
+    @property
+    def semantics(self):
+        return self.config.semantics
+
+    @semantics.setter
+    def semantics(self, value):
+        self.config.semantics = value
+
     def missing_rules(self, rules):
         return oset().union(*[rule.missing_rules(rules) for rule in self.rules])
 
@@ -1088,60 +1051,17 @@ class Grammar(Model):
         for rule in self.rules:
             rule._follow_set = fl[rule.name]
 
-    def parse(self,
-              text,
-              rule_name=None,
-              start=None,
-              filename=None,
-              semantics=None,
-              trace=False,
-              context=None,
-              whitespace=None,
-              ignorecase=None,
-              left_recursion=None,
-              comments_re=None,
-              eol_comments_re=None,
-              parseinfo=None,
-              nameguard=None,
-              namechars=None,
-              **kwargs):  # pylint: disable=W0221
-        start = start if start is not None else rule_name
+    def parse(self, text: str, /, start=None, context=None, config: ParserConfig = None, **settings):  # type: ignore
+        config = self.config.replace_config(config)
+        config = config.replace(**settings)
+        config = config.replace(start=start)
+
+        start = start if start is not None else config.effective_rule_name()
         start = start if start is not None else self.rules[0].name
+        config.replace(start=start)
 
-        ctx = context or ModelContext(
-            self.rules,
-            trace=trace,
-            keywords=self.keywords,
-            **kwargs)
-
-        semantics = notnone(semantics, self.semantics)
-        left_recursion = notnone(left_recursion, self.left_recursion)
-        parseinfo = notnone(parseinfo, self._use_parseinfo)
-        comments_re = notnone(comments_re, self.comments_re)
-        eol_comments_re = notnone(eol_comments_re, self.eol_comments_re)
-        nameguard = notnone(nameguard, self.nameguard)
-        namechars = notnone(namechars, self.namechars)
-        whitespace = notnone(whitespace, self.whitespace)
-        ignorecase = notnone(ignorecase, self.ignorecase)
-        if whitespace:
-            whitespace = re.compile(whitespace)
-
-        return ctx.parse(
-            text,
-            rule_name=start,
-            filename=filename,
-            semantics=semantics,
-            trace=trace,
-            whitespace=whitespace,
-            ignorecase=ignorecase,
-            comments_re=comments_re,
-            eol_comments_re=eol_comments_re,
-            left_recursion=left_recursion,
-            parseinfo=parseinfo,
-            nameguard=nameguard,
-            namechars=namechars,
-            **kwargs
-        )
+        ctx = context if context else ModelContext(self.rules, config=config)
+        return ctx.parse(text, start, config=config)
 
     def nodecount(self):
         return 1 + sum(r.nodecount() for r in self.rules)
diff --git a/tatsu/infos.py b/tatsu/infos.py
index 30a9ac5..5cbe8f8 100644
--- a/tatsu/infos.py
+++ b/tatsu/infos.py
@@ -1,10 +1,122 @@
 from __future__ import annotations
 
+import copy
 from collections import namedtuple
-from dataclasses import dataclass, field
-from typing import Any
+import dataclasses
+from typing import (
+    Any,
+    Mapping,
+    Optional,
+    Type,
+    Union,
+)
 
 from .ast import AST
+from .util.unicode_characters import C_DERIVE
+
+
+COMMENTS_RE = r'\(\*((?:.|\n)*?)\*\)'
+EOL_COMMENTS_RE = r'#([^\n]*?)$'
+
+
+@dataclasses.dataclass
+class ParserConfig:
+    owner: object = None
+    name: Optional[str] = 'Test'
+    filename: str = ''
+    encoding: str = 'utf-8'
+
+    start: Optional[str] = None  # FIXME
+    start_rule: Optional[str] = None  # FIXME
+
+    comments_re: Optional[str] = COMMENTS_RE
+    eol_comments_re: Optional[str] = EOL_COMMENTS_RE
+
+    tokenizercls: Optional[Type] = None  # FIXME
+    semantics: Optional[Type] = None
+
+    comment_recovery: bool = False
+    memoize_lookaheads: bool = True
+
+    colorize: bool = False
+    trace: bool = False
+    trace_filename: bool = False
+    trace_length: int = 72
+    trace_separator: str = C_DERIVE
+
+    # parser directives
+    grammar: Optional[str] = None
+    left_recursion: bool = True
+
+    comments: Optional[str] = None
+    eol_comments: Optional[str] = None
+    keywords: Union[list[str], set[str]] = dataclasses.field(default_factory=list)  # type: ignore
+
+    ignorecase: Optional[bool] = False
+    namechars: str = ''
+    nameguard: Optional[bool] = None  # implied by namechars
+    whitespace: Optional[str] = None
+
+    parseinfo: bool = False
+
+    def __post_init__(self):  # pylint: disable=W0235
+        if self.ignorecase:
+            self.keywords = [k.upper() for k in self.keywords]
+        if self.comments:
+            self.comments_re = self.comments
+        if self.eol_comments:
+            self.eol_comments_re = self.eol_comments
+
+    @classmethod
+    def new(cls, config: Optional[ParserConfig], owner: Any = None, **settings: Any) -> ParserConfig:
+        result = cls(owner=owner)
+        if config is not None:
+            result = config.replace_config(config)
+        return result.replace(**settings)
+
+    def effective_rule_name(self):
+        # note: there are legacy reasons for this mess
+        return (
+            self.start_rule or
+            self.start
+        )
+
+    def _find_common(self, **settings: Any) -> Mapping[str, Any]:
+        return {
+            name: value
+            for name, value in settings.items()
+            if value is not None and hasattr(self, name)
+        }
+
+    def replace_config(self, other: Optional[ParserConfig]) -> ParserConfig:
+        if other is None:
+            return self
+        elif not isinstance(other, ParserConfig):
+            raise TypeError(f'Unexpected type {type(other).__name__}')
+        else:
+            return self.replace(**vars(other))
+
+    def replace(self, **settings: Any) -> ParserConfig:
+        overrides = self._find_common(**settings)
+        result = dataclasses.replace(self, **overrides)
+        if 'grammar' in overrides:
+            result.name = result.grammar
+        return result
+
+    def merge(self, **settings: Any) -> ParserConfig:
+        overrides = self._find_common(**settings)
+        overrides = {
+            name: value for name, value in overrides.items()
+            if getattr(self, name, None) is None
+        }
+        return self.replace(**overrides)
+
+    def asdict(self):
+        # warning: it seems dataclasses.asdict does a deepcopy
+        # result = dataclasses.asdict(self)
+        result = copy.copy(vars(self))
+        result.pop('owner', None)
+        return result
 
 
 class PosLine(namedtuple('_PosLine', ['start', 'line', 'length'])):
@@ -119,8 +231,8 @@ RuleResult = namedtuple(
 )
 
 
-@dataclass
+@dataclasses.dataclass
 class ParseState(object):
     pos: int = 0
-    ast: AST = field(default_factory=AST)
+    ast: AST = dataclasses.field(default_factory=AST)
     cst: Any = None
diff --git a/tatsu/objectmodel.py b/tatsu/objectmodel.py
index eab5670..27e25e0 100644
--- a/tatsu/objectmodel.py
+++ b/tatsu/objectmodel.py
@@ -51,11 +51,13 @@ class Node(object):
 
     @property
     def line(self):
-        return self.parseinfo.line
+        if self.parseinfo:
+            return self.parseinfo.line
 
     @property
     def endline(self):
-        return self.parseinfo.endline
+        if self.parseinfo:
+            return self.parseinfo.endline
 
     def text_lines(self):
         return self.parseinfo.text_lines()
@@ -65,7 +67,7 @@ class Node(object):
 
     @property
     def col(self):
-        return self.line_info.col
+        return self.line_info.col if self.line_info else None
 
     @property
     def ctx(self):
diff --git a/tatsu/parser_semantics.py b/tatsu/parser_semantics.py
index 068a5e7..f760628 100644
--- a/tatsu/parser_semantics.py
+++ b/tatsu/parser_semantics.py
@@ -119,13 +119,14 @@ class EBNFGrammarSemantics(ModelBuilderSemantics):
 
     def grammar(self, ast, *args):
         directives = {d.name: d.value for d in flatten(ast.directives)}
-        keywords = set(flatten(ast.keywords) or [])
+        keywords = list(flatten(ast.keywords)) or []
 
         if directives.get('whitespace') in ('None', 'False'):
             directives['whitespace'] = ''
 
+        name = self.grammar_name if self.grammar_name else directives.get('grammar', None)
         return grammars.Grammar(
-            self.grammar_name,
+            name,
             list(self.rules.values()),
             directives=directives,
             keywords=keywords
diff --git a/tatsu/synth.py b/tatsu/synth.py
index 59865b4..e627a11 100644
--- a/tatsu/synth.py
+++ b/tatsu/synth.py
@@ -1,4 +1,6 @@
-__REGISTRY = vars()
+from typing import Any
+
+__REGISTRY: dict[str, Any] = vars()
 
 
 class _Synthetic(object):
diff --git a/tatsu/tool.py b/tatsu/tool.py
index 18bca71..5a2a52a 100644
--- a/tatsu/tool.py
+++ b/tatsu/tool.py
@@ -14,6 +14,7 @@ from tatsu.util import eval_escapes
 from tatsu.exceptions import ParseException
 from tatsu.parser import GrammarGenerator
 from tatsu.semantics import ModelBuilderSemantics
+from tatsu.infos import ParserConfig
 
 # we hook the tool to the Python code generator as the default
 from tatsu.codegen.python import codegen as pythoncg
@@ -153,15 +154,15 @@ def parse_args():
 __compiled_grammar_cache = {}  # type: ignore
 
 
-def compile(grammar, name=None, semantics=None, asmodel=False, **kwargs):
+def compile(grammar, name=None, semantics=None, asmodel=False, config: ParserConfig = None, **settings):
     cache = __compiled_grammar_cache
 
-    key = grammar
+    key = (name, grammar, id(semantics))
     if key in cache:
         model = cache[key]
     else:
-        gen = GrammarGenerator(name, **kwargs)
-        model = cache[key] = gen.parse(grammar, **kwargs)
+        gen = GrammarGenerator(name, config=config, **settings)
+        model = cache[key] = gen.parse(grammar, config=config, **settings)
 
     if semantics is not None:
         model.semantics = semantics
@@ -171,31 +172,31 @@ def compile(grammar, name=None, semantics=None, asmodel=False, **kwargs):
     return model
 
 
-def parse(grammar, input, start=None, name=None, semantics=None, asmodel=False, **kwargs):
-    model = compile(grammar, name=name, semantics=semantics, asmodel=asmodel)
-    return model.parse(input, start=start, semantics=semantics, **kwargs)
+def parse(grammar, input, start=None, name=None, semantics=None, asmodel=False, config: ParserConfig = None, **settings):
+    model = compile(grammar, name=name, semantics=semantics, asmodel=asmodel, config=config, **settings)
+    return model.parse(input, start=start, semantics=semantics, config=config, **settings)
 
 
-def to_python_sourcecode(grammar, name=None, filename=None, **kwargs):
-    model = compile(grammar, name=name, filename=filename, **kwargs)
+def to_python_sourcecode(grammar, name=None, filename=None, config: ParserConfig = None, **settings):
+    model = compile(grammar, name=name, filename=filename, config=config, **settings)
     return pythoncg(model)
 
 
-def to_python_model(grammar, name=None, filename=None, base_type=None, **kwargs):
-    model = compile(grammar, name=name, filename=filename, **kwargs)
+def to_python_model(grammar, name=None, filename=None, base_type=None, config: ParserConfig = None, **settings):
+    model = compile(grammar, name=name, filename=filename, config=config, **settings)
     return objectmodel.codegen(model, base_type=base_type)
 
 
 # for backwards compatibility. Use `compile()` instead
-def genmodel(name=None, grammar=None, semantics=None, **kwargs):
+def genmodel(name=None, grammar=None, semantics=None, config: ParserConfig = None, **settings):
     if grammar is None:
         raise ParseException('grammar is None')
 
-    return compile(grammar, name=name, semantics=semantics, **kwargs)
+    return compile(grammar, name=name, semantics=semantics, config=config, **settings)
 
 
-def gencode(name=None, grammar=None, trace=False, filename=None, codegen=pythoncg, **kwargs):
-    model = compile(grammar, name=name, filename=filename, trace=trace, **kwargs)
+def gencode(name=None, grammar=None, trace=False, filename=None, codegen=pythoncg, config: ParserConfig = None, **settings):
+    model = compile(grammar, name=name, filename=filename, trace=trace, config=config, **settings)
     return codegen(model)
 
 
diff --git a/tatsu/util/misc.py b/tatsu/util/misc.py
new file mode 100644
index 0000000..9b28ff6
--- /dev/null
+++ b/tatsu/util/misc.py
@@ -0,0 +1,92 @@
+# flake8: noqa
+from __future__ import annotations
+
+
+def check_pickle(xThing, lTested = None):
+    # https://stackoverflow.com/a/50049341/545637
+
+    # FIXME: clean up an use exceptions
+
+    import pickle
+    lTested = [] if lTested is None else lTested
+    if id(xThing) in lTested:
+        return lTested
+    sType = type(xThing).__name__
+    print('Testing {0}...'.format(sType))
+
+    if sType in ['type','int','str', 'bool', 'NoneType', 'unicode']:
+        print('...too easy')
+        return lTested
+    if sType == 'dict':
+        print('...testing members')
+        for k in xThing:
+            lTested = check_pickle(xThing[k], lTested)
+        print('...tested members')
+        return lTested
+    if sType == 'list':
+        print('...testing members')
+        for x in xThing:
+            lTested = check_pickle(x)
+        print('...tested members')
+        return lTested
+
+    lTested.append(id(xThing))
+    oClass = type(xThing)
+
+    for s in dir(xThing):
+        if s.startswith('_'):
+            print('...skipping *private* thingy')
+            continue
+        #if it is an attribute: Skip it
+        try:
+            xClassAttribute = oClass.__getattribute__(oClass,s)
+        except (AttributeError, TypeError):
+            pass
+        else:
+            if type(xClassAttribute).__name__ == 'property':
+                print('...skipping property')
+                continue
+
+        xAttribute = xThing.__getattribute__(s)
+        print('Testing {0}.{1} of type {2}'.format(sType,s,type(xAttribute).__name__))
+        if type(xAttribute).__name__ == 'function':
+            print("...skipping function")
+            continue
+        if type(xAttribute).__name__ in ['method', 'instancemethod']:
+            print('...skipping method')
+            continue
+        if type(xAttribute).__name__ == 'HtmlElement':
+            continue
+        if type(xAttribute) == dict:
+            print('...testing dict values for {0}.{1}'.format(sType,s))
+            for k in xAttribute:
+                lTested = check_pickle(xAttribute[k])
+                continue
+            print('...finished testing dict values for {0}.{1}'.format(sType,s))
+
+        try:
+            oIter = xAttribute.__iter__()
+        except (AttributeError, TypeError):
+            pass
+        except AssertionError:
+            pass #lxml elements do this
+        else:
+            print('...testing iter values for {0}.{1} of type {2}'.format(sType,s,type(xAttribute).__name__))
+            for x in xAttribute:
+                lTested = check_pickle(x, lTested)
+            print('...finished testing iter values for {0}.{1}'.format(sType,s))
+
+        try:
+            xAttribute.__dict__
+        except AttributeError:
+            pass
+        else:
+            #this attribute should be explored seperately...
+            lTested = check_pickle(xAttribute, lTested)
+            continue
+        print(0, xThing, xAttribute)
+        pickle.dumps(xAttribute)
+
+    print('Testing {0} as complete object'.format(sType))
+    pickle.dumps(xThing)
+    return lTested


Test patch:
diff --git a/test/grammar/directive_test.py b/test/grammar/directive_test.py
index 90dab7a..55193f2 100644
--- a/test/grammar/directive_test.py
+++ b/test/grammar/directive_test.py
@@ -67,7 +67,7 @@ class DirectiveTests(unittest.TestCase):
         '''
         model = tatsu.compile(grammar, "test")
         self.assertFalse(model.directives.get('left_recursion'))
-        self.assertFalse(model.left_recursion)
+        self.assertFalse(model.config.left_recursion)
 
         code = codegen(model)
         compile('test.py', code, EXEC)
@@ -156,6 +156,6 @@ class DirectiveTests(unittest.TestCase):
         '''
 
         model = tatsu.compile(grammar)
-        self.assertFalse(model.nameguard)
+        self.assertFalse(model.config.nameguard)
         self.assertEqual(['2', '3'], model.parse('23'))
         self.assertEqual(['x', 'x'], model.parse('xx'))
diff --git a/test/grammar/semantics_test.py b/test/grammar/semantics_test.py
index 40c267b..f988ca8 100644
--- a/test/grammar/semantics_test.py
+++ b/test/grammar/semantics_test.py
@@ -2,9 +2,12 @@
 from __future__ import generator_stop
 
 import unittest
+from typing import Any
 
 from tatsu.tool import compile
 from tatsu.semantics import ModelBuilderSemantics
+from tatsu import synth
+from tatsu.model import Node
 
 
 class MyNode(object):
@@ -41,10 +44,7 @@ class SemanticsTests(unittest.TestCase):
         self.assertEqual('5.4.3.2.1', ast)
 
     def test_builder_subclassing(self):
-        from tatsu import synth
-        from tatsu.model import Node
-
-        registry = getattr(synth, "__REGISTRY")
+        registry = getattr(synth, "__REGISTRY")  # type: dict[str, Any]
 
         grammar = '''
             @@grammar :: Test
@@ -54,6 +54,7 @@ class SemanticsTests(unittest.TestCase):
         model = compile(grammar, asmodel=True)
         model.parse("")
 
+        print(f'{registry=}')
         A = registry["A"]
         B = registry["B"]
         C = registry["C"]
diff --git a/test/parser_equivalence_test.py b/test/parser_equivalence_test.py
index f810eeb..072a91e 100644
--- a/test/parser_equivalence_test.py
+++ b/test/parser_equivalence_test.py
@@ -63,7 +63,7 @@ def generate_and_load_parser(name, grammar):
 
 
 def test_model_parse():
-    model = compile(grammar=GRAMMAR)
+    model = compile(name='Test', grammar=GRAMMAR)
     assert OUTPUT == model.parse(INPUT)