mapres 3.dev14__tar.gz → 3.dev16__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. {mapres-3.dev14/src/mapres.egg-info → mapres-3.dev16}/PKG-INFO +1 -1
  2. {mapres-3.dev14 → mapres-3.dev16}/pyproject.toml +1 -1
  3. {mapres-3.dev14 → mapres-3.dev16}/src/mapres/datamap.py +44 -14
  4. mapres-3.dev16/src/mapres/evaluator.py +221 -0
  5. {mapres-3.dev14 → mapres-3.dev16}/src/mapres/exceptions.py +3 -1
  6. {mapres-3.dev14 → mapres-3.dev16}/src/mapres/resolver.py +19 -2
  7. {mapres-3.dev14 → mapres-3.dev16}/src/mapres/tokenizer.py +69 -23
  8. {mapres-3.dev14 → mapres-3.dev16/src/mapres.egg-info}/PKG-INFO +1 -1
  9. mapres-3.dev14/src/mapres/evaluator.py +0 -119
  10. {mapres-3.dev14 → mapres-3.dev16}/LICENSE +0 -0
  11. {mapres-3.dev14 → mapres-3.dev16}/README.md +0 -0
  12. {mapres-3.dev14 → mapres-3.dev16}/setup.cfg +0 -0
  13. {mapres-3.dev14 → mapres-3.dev16}/src/mapres/__init__.py +0 -0
  14. {mapres-3.dev14 → mapres-3.dev16}/src/mapres/ast.py +0 -0
  15. {mapres-3.dev14 → mapres-3.dev16}/src/mapres/cache.py +0 -0
  16. {mapres-3.dev14 → mapres-3.dev16}/src/mapres/layers.py +0 -0
  17. {mapres-3.dev14 → mapres-3.dev16}/src/mapres/maps/__init__.py +0 -0
  18. {mapres-3.dev14 → mapres-3.dev16}/src/mapres/maps/color.py +0 -0
  19. {mapres-3.dev14 → mapres-3.dev16}/src/mapres/maps/time.py +0 -0
  20. {mapres-3.dev14 → mapres-3.dev16}/src/mapres/parser.py +0 -0
  21. {mapres-3.dev14 → mapres-3.dev16}/src/mapres.egg-info/SOURCES.txt +0 -0
  22. {mapres-3.dev14 → mapres-3.dev16}/src/mapres.egg-info/dependency_links.txt +0 -0
  23. {mapres-3.dev14 → mapres-3.dev16}/src/mapres.egg-info/requires.txt +0 -0
  24. {mapres-3.dev14 → mapres-3.dev16}/src/mapres.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mapres
3
- Version: 3.dev14
3
+ Version: 3.dev16
4
4
  Summary: A powerfull mapping utility
5
5
  Author: iFamished
6
6
  License: MIT
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
7
7
 
8
8
  [project]
9
9
  name = "mapres"
10
- version = "3-dev.14"
10
+ version = "3-dev.16"
11
11
  description = "A powerfull mapping utility"
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.9"
@@ -35,7 +35,10 @@ class DataMap:
35
35
  - "callable": map exposes callable methods for nested calls
36
36
  '''
37
37
  __syntax__: str = None
38
- __mode__: str | None = None # "dynamic", "config", "callable", or None
38
+ __mode__: str | None = None # "dynamic", "config", "callable", or None
39
+ __recursive__: bool = False
40
+ __missing_key__: str = 'error' # 'error', 'silent', 'placeholder'
41
+ __ignore_delimiters__: bool = False
39
42
 
40
43
  def as_map(self):
41
44
  '''
@@ -47,8 +50,9 @@ class DataMap:
47
50
  '''
48
51
  result = {}
49
52
 
53
+ # dataclass fields
50
54
  for f in fields(self):
51
- if not f.init or f.name in ('__syntax__', '__mode__'):
55
+ if not f.init or f.name.startswith('__'):
52
56
  continue
53
57
 
54
58
  val = getattr(self, f.name)
@@ -63,24 +67,58 @@ class DataMap:
63
67
 
64
68
  result[f.name] = val
65
69
 
70
+ # nested classes > nested dicts
71
+ for name, obj in self.__class__.__dict__.items():
72
+ if name.startswith('__'):
73
+ continue
74
+ if isinstance(obj, type):
75
+ result[name] = self._class_to_dict(obj)
76
+
66
77
  return result
67
78
 
79
+ def _class_to_dict(self, cls):
80
+ out = {}
81
+
82
+ if is_dataclass(cls):
83
+ dummy = cls()
84
+ for f in fields(dummy):
85
+ if not f.init:
86
+ continue
87
+ out[f.name] = getattr(dummy, f.name)
88
+
89
+ for name, obj in cls.__dict__.items():
90
+ if name.startswith('__'):
91
+ continue
92
+ if isinstance(obj, type):
93
+ out[name] = self._class_to_dict(obj)
94
+
95
+ return out
96
+
97
+
98
+ def datamap(_cls=None, *, syntax=None, mode=None, recursive=None,
99
+ missing_key=None, ignore_delimiters=None):
68
100
 
69
- # main decorator factory
70
- def datamap(_cls=None, *, syntax=None, mode=None):
71
- '''@datamap decorator'''
72
101
  def wrap(cls):
73
102
  cls.__syntax__ = syntax
74
103
  cls.__mode__ = mode
104
+ cls.__recursive__ = recursive
105
+ cls.__missing_key__ = missing_key or 'error'
106
+ cls.__ignore_delimiters__ = ignore_delimiters or False
107
+
75
108
  namespace = dict(cls.__dict__)
76
109
  namespace['__dict__'] = {}
77
110
  rules_obj = namespace.get('rules', None)
111
+
78
112
  cls = type(cls.__name__, (DataMap,), namespace)
113
+
79
114
  if rules_obj is not None:
80
115
  setattr(cls, 'rules', rules_obj)
116
+
81
117
  return dataclass(frozen=False)(cls)
82
- if _cls is not None:
118
+
119
+ if _cls is not None and isinstance(_cls, type):
83
120
  return wrap(_cls)
121
+
84
122
  return wrap
85
123
 
86
124
 
@@ -98,30 +136,23 @@ class _SyntaxFactory:
98
136
  self._syntax = syntax_name
99
137
 
100
138
  def __call__(self, _cls=None, **kwargs):
101
- # Case 1: bare decorator: @datamap.angles
102
139
  if _cls is not None and isinstance(_cls, type):
103
140
  return datamap(_cls, syntax=self._syntax)
104
-
105
- # Case 2: decorator with args: @datamap.angles(mode='config')
106
141
  return datamap(syntax=self._syntax, **kwargs)
107
142
 
108
143
  @property
109
144
  def config(self):
110
- # Case 3: @datamap.angles.config
111
145
  return datamap(syntax=self._syntax, mode='config')
112
146
 
113
147
  @property
114
148
  def dynamic(self):
115
- # @datamap.angles.dynamic
116
149
  return datamap(syntax=self._syntax, mode='dynamic')
117
150
 
118
151
  @property
119
152
  def callable(self):
120
- # @datamap.angles.callable
121
153
  return datamap(syntax=self._syntax, mode='callable')
122
154
 
123
155
 
124
- # build syntax shortcuts
125
156
  datamap.braces = _SyntaxFactory(syntax.braces)
126
157
  datamap.dollars = _SyntaxFactory(syntax.dollars)
127
158
  datamap.angles = _SyntaxFactory(syntax.angles)
@@ -132,7 +163,6 @@ datamap.at_tags = _SyntaxFactory(syntax.at_tags)
132
163
  datamap.hash_tags = _SyntaxFactory(syntax.hash_tags)
133
164
  datamap.paren_dollars = _SyntaxFactory(syntax.paren_dollars)
134
165
 
135
- # global mode shortcuts
136
166
  datamap.config = datamap(mode='config')
137
167
  datamap.dynamic = datamap(mode='dynamic')
138
168
  datamap.callable = datamap(mode='callable')
@@ -0,0 +1,221 @@
1
+ from .ast import TemplateNode, TextNode, IdentNode, CallNode
2
+ from .tokenizer import Tokenizer
3
+ from .parser import Parser
4
+ from .exceptions import MapResError, MissingKeyError
5
+
6
+
7
+ class EvaluationError(MapResError):
8
+ pass
9
+
10
+
11
+ class Evaluator:
12
+ def __init__(self, layerstack, ctx=None):
13
+ self.layerstack = layerstack
14
+ self.ctx = ctx or {}
15
+ self._seen = set() # cycle detection
16
+
17
+ # public api
18
+ def evaluate(self, node):
19
+ return self._eval(node)
20
+
21
+ # internal dispatch
22
+ def _eval(self, node):
23
+ if isinstance(node, TemplateNode):
24
+ return self._eval_template(node)
25
+
26
+ if isinstance(node, TextNode):
27
+ return node.text
28
+
29
+ if isinstance(node, IdentNode):
30
+ return self._eval_ident(node)
31
+
32
+ if isinstance(node, CallNode):
33
+ return self._eval_call(node)
34
+
35
+ raise EvaluationError(f'Unknown AST node: {node}')
36
+
37
+ # template
38
+ def _eval_template(self, node: TemplateNode):
39
+ parts = []
40
+ for child in node.children:
41
+ parts.append(self._eval(child))
42
+ out = ''.join(parts)
43
+
44
+ # recursion over full template output
45
+ if self._has_recursive_maps():
46
+ seen = set()
47
+ depth = 0
48
+ max_depth = self._get_max_depth()
49
+
50
+ while depth < max_depth:
51
+ new_out = self._re_resolve(out)
52
+ if new_out == out:
53
+ break
54
+ if new_out in seen:
55
+ raise EvaluationError('Cycle detected in template recursion')
56
+ seen.add(new_out)
57
+ out = new_out
58
+ depth += 1
59
+
60
+ return out
61
+
62
+ # identifier
63
+ def _eval_ident(self, node: IdentNode):
64
+ name = node.name
65
+
66
+ # ignore_delimiters: treat token as literal for this syntax
67
+ if self._syntax_ignores_delimiters(node.syntax):
68
+ return self._literal_from_syntax(name, node.syntax)
69
+
70
+ # cycle detection
71
+ if name in self._seen:
72
+ raise EvaluationError(f"Cycle detected in identifier '{name}'")
73
+ self._seen.add(name)
74
+
75
+ parts = name.split('.')
76
+
77
+ # 1. ctx first
78
+ val = self._lookup_hierarchical(self.ctx, parts)
79
+ if val is not None:
80
+ self._seen.remove(name)
81
+ return str(val)
82
+
83
+ # 2. syntax-bound maps
84
+ for m in self.layerstack.all_maps():
85
+ if getattr(m, '__syntax__', None) not in (None, node.syntax):
86
+ continue
87
+
88
+ d = self._map_to_dict(m)
89
+ val = self._lookup_hierarchical(d, parts)
90
+ if val is not None:
91
+ self._seen.remove(name)
92
+ return str(val)
93
+
94
+ self._seen.remove(name)
95
+ raise MissingKeyError(name)
96
+
97
+ # call (nested token)
98
+ def _eval_call(self, node: CallNode):
99
+ outer = node.name
100
+
101
+ # ignore_delimiters: treat call token as literal
102
+ if self._syntax_ignores_delimiters(node.syntax):
103
+ arg_text = self._eval(node.arg)
104
+ return self._literal_from_syntax(outer, node.syntax, is_call=True, arg_text=arg_text)
105
+
106
+ if outer in self._seen:
107
+ raise EvaluationError(f"Cycle detected in call '{outer}'")
108
+ self._seen.add(outer)
109
+
110
+ arg_value = self._eval(node.arg)
111
+ parts = [outer, arg_value]
112
+
113
+ # ctx first
114
+ val = self._lookup_hierarchical(self.ctx, parts)
115
+ if val is not None:
116
+ self._seen.remove(outer)
117
+ return str(val)
118
+
119
+ # syntax-bound maps
120
+ for m in self.layerstack.all_maps():
121
+ if getattr(m, '__syntax__', None) not in (None, node.syntax):
122
+ continue
123
+
124
+ d = self._map_to_dict(m)
125
+ val = self._lookup_hierarchical(d, parts)
126
+ if val is not None:
127
+ self._seen.remove(outer)
128
+ return str(val)
129
+
130
+ self._seen.remove(outer)
131
+ raise MissingKeyError(f'{outer}({arg_value})')
132
+
133
+ # helpers
134
+ def _map_to_dict(self, m):
135
+ if hasattr(m, 'as_map'):
136
+ return m.as_map()
137
+ if isinstance(m, dict):
138
+ return m
139
+ raise EvaluationError(f'Invalid map object: {m}')
140
+
141
+ def _lookup_hierarchical(self, root, parts):
142
+ cur = root
143
+ for p in parts:
144
+ if not isinstance(cur, dict):
145
+ return None
146
+ if p not in cur:
147
+ return None
148
+ cur = cur[p]
149
+ return cur
150
+
151
+ def _has_recursive_maps(self):
152
+ for m in self.layerstack.all_maps():
153
+ if getattr(m, '__recursive__', False):
154
+ return True
155
+ return False
156
+
157
+ def _get_max_depth(self):
158
+ depths = [
159
+ getattr(m, '__max_depth__', 10)
160
+ for m in self.layerstack.all_maps()
161
+ if getattr(m, '__recursive__', False)
162
+ ]
163
+ return max(depths) if depths else 0
164
+
165
+ def _re_resolve(self, text: str) -> str:
166
+ tokens = Tokenizer(text).tokenize()
167
+ ast = Parser(tokens).parse()
168
+ return self.evaluate(ast)
169
+
170
+ def _syntax_ignores_delimiters(self, syntax_name: str) -> bool:
171
+ for m in self.layerstack.all_maps():
172
+ if getattr(m, '__syntax__', None) == syntax_name:
173
+ if getattr(m, '__ignore_delimiters__', False):
174
+ return True
175
+ return False
176
+
177
+ def _literal_from_syntax(self, name: str, syntax_name: str, is_call: bool = False, arg_text: str | None = None) -> str:
178
+ if not is_call:
179
+ if syntax_name == 'colons':
180
+ return f':{name}:'
181
+ if syntax_name == 'braces':
182
+ return f'{{{{{name}}}}}'
183
+ if syntax_name == 'dollars':
184
+ return f'${{{name}}}'
185
+ if syntax_name == 'angles':
186
+ return f'<{name}>'
187
+ if syntax_name == 'pipes':
188
+ return f'|{name}|'
189
+ if syntax_name == 'percents':
190
+ return f'%{name}%'
191
+ if syntax_name == 'paren_dollars':
192
+ return f'$({name})'
193
+ if syntax_name == 'at_tags':
194
+ return f'@{name}@'
195
+ if syntax_name == 'hash_tags':
196
+ return f'#{name}#'
197
+ return name
198
+
199
+ # call literal: syntax-specific wrapping of name(arg_text)
200
+ arg = arg_text or ''
201
+ inner = f'{name}({arg})'
202
+
203
+ if syntax_name == 'colons':
204
+ return f':{inner}:'
205
+ if syntax_name == 'braces':
206
+ return f'{{{{{inner}}}}}'
207
+ if syntax_name == 'dollars':
208
+ return f'${{{inner}}}'
209
+ if syntax_name == 'angles':
210
+ return f'<{inner}>'
211
+ if syntax_name == 'pipes':
212
+ return f'|{inner}|'
213
+ if syntax_name == 'percents':
214
+ return f'%{inner}%'
215
+ if syntax_name == 'paren_dollars':
216
+ return f'$({inner})'
217
+ if syntax_name == 'at_tags':
218
+ return f'@{inner}@'
219
+ if syntax_name == 'hash_tags':
220
+ return f'#{inner}#'
221
+ return inner
@@ -2,7 +2,9 @@ class MapResError(Exception):
2
2
  pass
3
3
 
4
4
  class MissingKeyError(MapResError):
5
- pass
5
+ def __init__(self, key):
6
+ super().__init__(f'Missing key {key!r}')
7
+ self.key = key
6
8
 
7
9
  class MapSyntaxError(MapResError):
8
10
  pass
@@ -3,7 +3,7 @@ from .parser import Parser
3
3
  from .evaluator import Evaluator
4
4
  from .layers import LayerStack
5
5
  from .cache import LRUCache
6
- from .exceptions import MapResError
6
+ from .exceptions import MapResError, MissingKeyError
7
7
 
8
8
 
9
9
  class MapResolver:
@@ -65,8 +65,18 @@ class MapResolver:
65
65
 
66
66
  return result
67
67
 
68
+ except MissingKeyError as exc:
69
+ mode = self._resolve_missing_key_mode()
70
+ if mode == 'error':
71
+ raise MapResError(f'Resolver error: {exc}') from exc
72
+ if mode == 'silent':
73
+ return text
74
+ if mode == 'placeholder':
75
+ return f'<missing:{exc.key}>'
76
+ raise MapResError(f'Resolver error: {exc}') from exc
77
+
68
78
  except Exception as exc:
69
- raise MapResError(f"Resolver error: {exc}") from exc
79
+ raise MapResError(f'Resolver error: {exc}') from exc
70
80
 
71
81
  # ---------------------------------------
72
82
  # LAYERSTACK BUILDING
@@ -92,6 +102,13 @@ class MapResolver:
92
102
 
93
103
  return self.layers
94
104
 
105
+ def _resolve_missing_key_mode(self):
106
+ for m in self.layers.all_maps():
107
+ mode = getattr(m, '__missing_key__', None)
108
+ if mode is not None:
109
+ return mode
110
+ return 'error'
111
+
95
112
 
96
113
  # ---------------------------------------
97
114
  # SIMPLE GLOBAL USAGE
@@ -28,8 +28,8 @@ class Token:
28
28
 
29
29
  def __repr__(self):
30
30
  if self.value is not None:
31
- return f"Token({self.type}, {self.value!r}, pos={self.pos})"
32
- return f"Token({self.type}, pos={self.pos})"
31
+ return f'Token({self.type}, {self.value!r}, pos={self.pos})'
32
+ return f'Token({self.type}, pos={self.pos})'
33
33
 
34
34
 
35
35
  class Tokenizer:
@@ -38,10 +38,15 @@ class Tokenizer:
38
38
  self.i = 0
39
39
  self.n = len(text)
40
40
  self.tokens = []
41
+
42
+ # toggle states
41
43
  self.in_pipe = False
42
44
  self.in_percent = False
43
45
  self.in_colon = False
44
46
 
47
+ # mixed states
48
+ self.in_dollar = False
49
+
45
50
  def peek(self, k=0):
46
51
  idx = self.i + k
47
52
  if idx >= self.n:
@@ -68,6 +73,7 @@ class Tokenizer:
68
73
  while self.i < self.n:
69
74
  ch = self.peek()
70
75
 
76
+ # escapes
71
77
  if self.match(r'\:'):
72
78
  buf.append(':'); self.advance(2); continue
73
79
  if self.match(r'\<'):
@@ -89,43 +95,57 @@ class Tokenizer:
89
95
  if self.match(r'\)'):
90
96
  buf.append(')'); self.advance(2); continue
91
97
 
92
- if self.match("{{"):
98
+ # --- paired delimiters ---
99
+
100
+ # braces {{ }}
101
+ if self.match('{{'):
93
102
  flush_text()
94
103
  self.emit(TokenType.BRACE_OPEN)
95
104
  self.advance(2)
96
105
  continue
97
106
 
98
- if self.match("}}"):
107
+ if self.match('}}'):
99
108
  flush_text()
100
109
  self.emit(TokenType.BRACE_CLOSE)
101
110
  self.advance(2)
102
111
  continue
103
112
 
104
- if self.match("${"):
113
+ # angles < >
114
+ if self.match('<'):
105
115
  flush_text()
106
- self.emit(TokenType.DOLLAR_OPEN)
107
- self.advance(2)
116
+ self.emit(TokenType.ANGLE_OPEN)
117
+ self.advance()
108
118
  continue
109
119
 
110
- if self.match("}"):
120
+ if self.match('>'):
111
121
  flush_text()
112
- self.emit(TokenType.DOLLAR_CLOSE)
122
+ self.emit(TokenType.ANGLE_CLOSE)
113
123
  self.advance()
114
124
  continue
115
125
 
116
- if self.match("<"):
126
+ # --- mixed delimiters (dollars) ---
127
+
128
+ if self.match('${'):
117
129
  flush_text()
118
- self.emit(TokenType.ANGLE_OPEN)
119
- self.advance()
130
+ self.emit(TokenType.DOLLAR_OPEN)
131
+ self.in_dollar = True
132
+ self.advance(2)
120
133
  continue
121
134
 
122
- if self.match(">"):
135
+ if ch == '}':
123
136
  flush_text()
124
- self.emit(TokenType.ANGLE_CLOSE)
137
+ if self.in_dollar:
138
+ self.emit(TokenType.DOLLAR_CLOSE)
139
+ self.in_dollar = False
140
+ else:
141
+ buf.append('}')
125
142
  self.advance()
126
143
  continue
127
144
 
128
- if self.match("|"):
145
+ # --- toggle delimiters ---
146
+
147
+ # pipes |...|
148
+ if self.match('|'):
129
149
  flush_text()
130
150
  if not self.in_pipe:
131
151
  self.emit(TokenType.PIPE_OPEN)
@@ -136,7 +156,8 @@ class Tokenizer:
136
156
  self.advance()
137
157
  continue
138
158
 
139
- if self.match("%"):
159
+ # percents %...%
160
+ if self.match('%'):
140
161
  flush_text()
141
162
  if not self.in_percent:
142
163
  self.emit(TokenType.PERCENT_OPEN)
@@ -147,7 +168,8 @@ class Tokenizer:
147
168
  self.advance()
148
169
  continue
149
170
 
150
- if self.match(":"):
171
+ # colons :...:
172
+ if self.match(':'):
151
173
  flush_text()
152
174
  if not self.in_colon:
153
175
  self.emit(TokenType.COLON_OPEN)
@@ -158,27 +180,51 @@ class Tokenizer:
158
180
  self.advance()
159
181
  continue
160
182
 
161
- if ch == "(":
183
+ # --- parens ---
184
+
185
+ if ch == '(':
162
186
  flush_text()
163
187
  self.emit(TokenType.LPAREN)
164
188
  self.advance()
165
189
  continue
166
190
 
167
- if ch == ")":
191
+ if ch == ')':
168
192
  flush_text()
169
193
  self.emit(TokenType.RPAREN)
170
194
  self.advance()
171
195
  continue
172
196
 
173
- if ch.isalpha():
197
+ # --- IDENT (your rules) ---
198
+
199
+ if ch.isalnum():
174
200
  flush_text()
175
201
  start = self.i
176
- while self.peek().isalnum() or self.peek() in "._":
177
- self.advance()
202
+
203
+ # first char: [a-zA-Z0-9]
204
+ self.advance()
205
+
206
+ # middle chars: [a-zA-Z0-9._]*
207
+ while True:
208
+ nxt = self.peek()
209
+ if not nxt:
210
+ break
211
+ if nxt.isalnum() or nxt in '._':
212
+ self.advance()
213
+ else:
214
+ break
215
+
178
216
  ident = self.text[start:self.i]
179
- self.emit(TokenType.IDENT, ident)
217
+
218
+ # enforce: no leading/trailing '_', no '-'
219
+ if ident.startswith('_') or ident.endswith('_') or '-' in ident:
220
+ # treat as TEXT instead
221
+ buf.append(ident)
222
+ else:
223
+ self.emit(TokenType.IDENT, ident)
180
224
  continue
181
225
 
226
+ # --- fallback: TEXT ---
227
+
182
228
  buf.append(ch)
183
229
  self.advance()
184
230
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mapres
3
- Version: 3.dev14
3
+ Version: 3.dev16
4
4
  Summary: A powerfull mapping utility
5
5
  Author: iFamished
6
6
  License: MIT
@@ -1,119 +0,0 @@
1
- from .ast import TemplateNode, TextNode, IdentNode, CallNode
2
-
3
-
4
- class EvaluationError(Exception):
5
- pass
6
-
7
-
8
- class Evaluator:
9
- def __init__(self, layerstack, ctx=None):
10
- self.layerstack = layerstack
11
- self.ctx = ctx or {}
12
- self._seen = set() # cycle detection
13
-
14
- # public api
15
- def evaluate(self, node):
16
- return self._eval(node)
17
-
18
- # internal dispatch
19
- def _eval(self, node):
20
- if isinstance(node, TemplateNode):
21
- return self._eval_template(node)
22
-
23
- if isinstance(node, TextNode):
24
- return node.text
25
-
26
- if isinstance(node, IdentNode):
27
- return self._eval_ident(node)
28
-
29
- if isinstance(node, CallNode):
30
- return self._eval_call(node)
31
-
32
- raise EvaluationError(f"Unknown AST node: {node}")
33
-
34
- # template
35
- def _eval_template(self, node: TemplateNode):
36
- parts = []
37
- for child in node.children:
38
- parts.append(self._eval(child))
39
- return "".join(parts)
40
-
41
- # identifier
42
- def _eval_ident(self, node: IdentNode):
43
- name = node.name
44
-
45
- # cycle detection
46
- if name in self._seen:
47
- raise EvaluationError(f"Cycle detected in identifier '{name}'")
48
- self._seen.add(name)
49
-
50
- parts = name.split(".")
51
-
52
- # 1. ctx first
53
- val = self._lookup_hierarchical(self.ctx, parts)
54
- if val is not None:
55
- self._seen.remove(name)
56
- return str(val)
57
-
58
- # 2. syntax-bound maps
59
- for m in self.layerstack.all_maps():
60
- if getattr(m, "__syntax__", None) not in (None, node.syntax):
61
- continue
62
-
63
- d = self._map_to_dict(m)
64
- val = self._lookup_hierarchical(d, parts)
65
- if val is not None:
66
- self._seen.remove(name)
67
- return str(val)
68
-
69
- self._seen.remove(name)
70
- raise EvaluationError(f"Missing key '{name}'")
71
-
72
- # call (nested token)
73
- def _eval_call(self, node: CallNode):
74
- outer = node.name
75
-
76
- if outer in self._seen:
77
- raise EvaluationError(f"Cycle detected in call '{outer}'")
78
- self._seen.add(outer)
79
-
80
- arg_value = self._eval(node.arg)
81
- parts = [outer, arg_value]
82
-
83
- # ctx first
84
- val = self._lookup_hierarchical(self.ctx, parts)
85
- if val is not None:
86
- self._seen.remove(outer)
87
- return str(val)
88
-
89
- # syntax-bound maps
90
- for m in self.layerstack.all_maps():
91
- if getattr(m, "__syntax__", None) not in (None, node.syntax):
92
- continue
93
-
94
- d = self._map_to_dict(m)
95
- val = self._lookup_hierarchical(d, parts)
96
- if val is not None:
97
- self._seen.remove(outer)
98
- return str(val)
99
-
100
- self._seen.remove(outer)
101
- raise EvaluationError(f"Missing nested key '{outer}({arg_value})'")
102
-
103
- # helpers
104
- def _map_to_dict(self, m):
105
- if hasattr(m, "as_map"):
106
- return m.as_map()
107
- if isinstance(m, dict):
108
- return m
109
- raise EvaluationError(f"Invalid map object: {m}")
110
-
111
- def _lookup_hierarchical(self, root, parts):
112
- cur = root
113
- for p in parts:
114
- if not isinstance(cur, dict):
115
- return None
116
- if p not in cur:
117
- return None
118
- cur = cur[p]
119
- return cur
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes