nativelayout 1.2.0__py3-none-any.whl

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.
@@ -0,0 +1,1266 @@
1
+ """Expander: `.nl` -> plain HTML with {value}, if/for/include and styles (port of Expander.kt)."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+ import re
6
+ import threading
7
+ from dataclasses import dataclass, field
8
+ from decimal import Decimal
9
+
10
+ from . import scopes as Scopes
11
+ from .errors import NativeLayoutError, extract_line
12
+ from .layout_engine import LayoutWarning
13
+
14
+
15
+ @dataclass
16
+ class ExpandedScreen:
17
+ html: str
18
+ line_map: list[int]
19
+ warnings: list[LayoutWarning] = field(default_factory=list)
20
+ # The item of each repetition of a `for` that has an id or a class, by path (`p:7`, `p:7/o:2`).
21
+ items: dict[str, object] = field(default_factory=dict)
22
+
23
+
24
+ class ExpandError(NativeLayoutError):
25
+ def __init__(self, message: str, line: int, source: str,
26
+ word: str | None = None, suggestion: str | None = None):
27
+ super().__init__(message, line, extract_line(source, line), word, suggestion)
28
+
29
+
30
+ def _rx(p: str) -> re.Pattern:
31
+ return re.compile(p, re.ASCII)
32
+
33
+
34
+ KIND_TAG, KIND_CLASS, KIND_ID = 1, 2, 3
35
+ STATE_NONE, STATE_PRESSED, STATE_DISABLED, STATE_FOCUS = 0, 1, 2, 3
36
+ STATE_ATTRIBUTES = ("style-pressed", "style-disabled", "style-focus")
37
+ MAX_INCLUDE_LEVELS = 8
38
+ VOID_NAMES = frozenset({"br", "hr", "img", "input", "meta", "link"})
39
+
40
+ INCLUDE_START = _rx(r"^<include(?=[\s/>])")
41
+ KEY_ATTR = _rx(r'\skey\s*=\s*"([^"]*)"')
42
+ HAS_KEY = _rx(r"\skey\s*=")
43
+ FIELD_OR_ID = _rx(r"<(?:input|textarea)\b|\sid\s*=")
44
+ TAG_RE = _rx(r"<[^<>]*>")
45
+ TAG_NAME_RE = _rx(r"^<([A-Za-z][A-Za-z0-9-]*)")
46
+ ATTR_RE = _rx(r"""([A-Za-z_:][-A-Za-z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?""")
47
+ BRACE_RE = _rx(r"\\[{}]|\{([^{}]*)\}")
48
+ NAME_RE = _rx(r"[A-Za-z_][A-Za-z0-9_.]*")
49
+ SINGLE_VALUE_RE = _rx(r"\{\s*([A-Za-z_][A-Za-z0-9_.]*)\s*\}")
50
+ VAR_RE = _rx(r"var\(\s*(--[A-Za-z0-9_-]+)\s*\)")
51
+ RULE_RE = _rx(r"([^{}]+)\{([^{}]*)\}")
52
+ COMMENT_RE = _rx(r"/\*[\s\S]*?\*/")
53
+ FOR_RE = _rx(r"for\s+([A-Za-z_][A-Za-z0-9_]*)\s+in\s+([A-Za-z_][A-Za-z0-9_.]*):")
54
+ KEYWORD_RE = _rx(r"^(if|elif|for)\s")
55
+ ELSE_SPACE_RE = _rx(r"else\s+:")
56
+ TOKEN_RE = _rx(r'==|!=|<=|>=|<|>|\(|\)|"[^"]*"|-?\d+(?:\.\d+)?|[A-Za-z_][A-Za-z0-9_.]*')
57
+ TAG_SELECTOR = _rx(r"[A-Za-z][A-Za-z0-9]*")
58
+ CLASS_SELECTOR = _rx(r"\.[A-Za-z_][A-Za-z0-9_-]*")
59
+ ID_SELECTOR = _rx(r"#[A-Za-z_][A-Za-z0-9_-]*")
60
+ COMPARATORS = frozenset({"==", "!=", "<", ">", "<=", ">="})
61
+ WS_RE = _rx(r"\s+")
62
+
63
+ _MISSING = object()
64
+
65
+
66
+ def _first(m: re.Match, *idx: int) -> str | None:
67
+ for i in idx:
68
+ g = m.group(i)
69
+ if g is not None:
70
+ return g
71
+ return None
72
+
73
+
74
+ # ---------------------------------------------------------------- model
75
+ class Rule:
76
+ def __init__(self, kind, name, declarations, order, line, file, link_line, state=STATE_NONE):
77
+ self.kind = kind
78
+ self.name = name
79
+ self.declarations = declarations
80
+ self.order = order
81
+ self.line = line
82
+ self.file = file
83
+ self.link_line = link_line
84
+ self.state = state
85
+ self.resolved: list[tuple[str, str]] = []
86
+
87
+
88
+ class Attr:
89
+ __slots__ = ("key", "value")
90
+
91
+ def __init__(self, key: str, value: str | None):
92
+ self.key = key
93
+ self.value = value
94
+
95
+
96
+ class Scope:
97
+ def __init__(self, parent: "Scope | None", vars_: dict, path: str | None = None):
98
+ self.parent = parent
99
+ self.vars = vars_
100
+ self.path = path if path is not None else (parent.path if parent is not None else "")
101
+
102
+ def find(self, name: str):
103
+ s = self
104
+ while s is not None:
105
+ if name in s.vars:
106
+ return s.vars[name]
107
+ s = s.parent
108
+ return _MISSING
109
+
110
+ def has(self, name: str) -> bool:
111
+ return name in self.vars or (self.parent.has(name) if self.parent is not None else False)
112
+
113
+
114
+ class Node:
115
+ pass
116
+
117
+
118
+ class TextNode(Node):
119
+ def __init__(self, text: str, line: int):
120
+ self.text = text
121
+ self.line = line
122
+ self.parts: list | None = None
123
+
124
+
125
+ class Branch:
126
+ def __init__(self, condition: str | None, line: int):
127
+ self.condition = condition
128
+ self.line = line
129
+ self.nodes: list[Node] = []
130
+ self.tokens: list[str] | None = None
131
+
132
+
133
+ class IfNode(Node):
134
+ def __init__(self, line: int):
135
+ self.line = line
136
+ self.branches: list[Branch] = []
137
+
138
+
139
+ class ForNode(Node):
140
+ def __init__(self, line: int, variable: str, list_name: str):
141
+ self.line = line
142
+ self.variable = variable
143
+ self.list_name = list_name
144
+ self.nodes: list[Node] = []
145
+
146
+
147
+ class ParamPlan:
148
+ def __init__(self, name, constant, single, parts):
149
+ self.name = name
150
+ self.constant = constant
151
+ self.single = single
152
+ self.parts = parts
153
+
154
+
155
+ class IncludeNode(Node):
156
+ def __init__(self, line, component, params, key):
157
+ self.line = line
158
+ self.component = component
159
+ self.params = params
160
+ self.key = key
161
+
162
+
163
+ class Frame:
164
+ def __init__(self, open_line: int, nodes: list, if_node: IfNode | None):
165
+ self.open_line = open_line
166
+ self.nodes = nodes
167
+ self.if_node = if_node
168
+
169
+
170
+ class Control:
171
+ def __init__(self, type_: str, argument: str, variable: str = ""):
172
+ self.type = type_
173
+ self.argument = argument
174
+ self.variable = variable
175
+
176
+
177
+ @dataclass
178
+ class FileLayout:
179
+ links: list[tuple[str, int]]
180
+ style_start: int
181
+ style_end: int
182
+ body_start: int
183
+ body_end: int
184
+
185
+
186
+ class Component:
187
+ def __init__(self, name, source, lines, layout, include_line):
188
+ self.name = name
189
+ self.source = source
190
+ self.lines = lines
191
+ self.layout = layout
192
+ self.include_line = include_line
193
+ self.nodes: list[Node] = []
194
+
195
+
196
+ class IncludeTag:
197
+ def __init__(self, attrs: list[tuple[str, str | None]]):
198
+ self.attrs = attrs
199
+
200
+ def get(self, name: str) -> str | None:
201
+ n = name.lower()
202
+ for k, v in self.attrs:
203
+ if k.lower() == n:
204
+ return v
205
+ return None
206
+
207
+
208
+ class Part:
209
+ pass
210
+
211
+
212
+ class Lit(Part):
213
+ def __init__(self, s: str):
214
+ self.s = s
215
+
216
+
217
+ class Val(Part):
218
+ def __init__(self, inner: str):
219
+ self.inner = inner
220
+ self.name = inner.strip()
221
+ self.ok = NAME_RE.fullmatch(self.name) is not None
222
+ self.path = self.name.split(".")
223
+
224
+
225
+ class AttrPlan:
226
+ def __init__(self, orig, key, value, brace, single, text_parts):
227
+ self.orig = orig
228
+ self.key = key
229
+ self.value = value
230
+ self.brace = brace
231
+ self.single = single
232
+ self.text_parts = text_parts
233
+
234
+
235
+ class DynTag(Part):
236
+ def __init__(self, name, lower, self_close, rule_style, attrs, state_styles):
237
+ self.name = name
238
+ self.lower = lower
239
+ self.self_close = self_close
240
+ self.rule_style = rule_style
241
+ self.attrs = attrs
242
+ self.state_styles = state_styles
243
+ self.pre: str | None = None
244
+
245
+
246
+ class Prepared:
247
+ def __init__(self, rules, nodes):
248
+ self.rules = rules
249
+ self.nodes = nodes
250
+
251
+
252
+ # ---------------------------------------------------------------- cache
253
+ _cache: dict[str, Prepared] = {}
254
+ _cache_lock = threading.Lock()
255
+
256
+
257
+ class Expander:
258
+ @staticmethod
259
+ def expand(source: str, data: dict, resolve=None, dark: bool = False) -> ExpandedScreen:
260
+ return _ScreenExpander(source, data, resolve or (lambda name: None), dark).expand()
261
+
262
+ @staticmethod
263
+ def clear_cache() -> None:
264
+ with _cache_lock:
265
+ _cache.clear()
266
+
267
+
268
+ def _cached(key: str):
269
+ with _cache_lock:
270
+ return _cache.get(key)
271
+
272
+
273
+ def _store(key: str, prepared: Prepared) -> None:
274
+ with _cache_lock:
275
+ if len(_cache) >= 32:
276
+ _cache.clear()
277
+ _cache[key] = prepared
278
+
279
+
280
+ # ---------------------------------------------------------------- utilities
281
+ def _escape_html(s: str) -> str:
282
+ return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
283
+
284
+
285
+ def _is_num(v) -> bool:
286
+ return isinstance(v, (int, float)) and not isinstance(v, bool)
287
+
288
+
289
+ def _format_number(n) -> str:
290
+ if isinstance(n, float):
291
+ if math.isfinite(n) and n == math.floor(n) and abs(n) < 9.2e18:
292
+ return str(int(n))
293
+ if not math.isfinite(n):
294
+ return "NaN" if math.isnan(n) else ("Infinity" if n > 0 else "-Infinity")
295
+ a = abs(n)
296
+ if a >= 1e7 or (a < 1e-3 and a != 0.0):
297
+ sign, digits, exp = Decimal(repr(n)).normalize().as_tuple()
298
+ ds = "".join(map(str, digits))
299
+ e10 = exp + len(ds) - 1
300
+ return ("-" if sign else "") + ds[0] + "." + (ds[1:] or "0") + "E" + str(e10)
301
+ return repr(n)
302
+ return str(n)
303
+
304
+
305
+ def _singular(name: str) -> str:
306
+ last = name.rpartition(".")[2]
307
+ return last[:-1] if len(last) > 1 and last.endswith("s") else "item"
308
+
309
+
310
+ def _equals(a, b) -> bool:
311
+ if isinstance(a, bool) != isinstance(b, bool):
312
+ return False
313
+ return a == b
314
+
315
+
316
+ def _join_path(frm: str, rel: str) -> str:
317
+ d = frm.rpartition("/")[0] if "/" in frm else ""
318
+ return rel if not d else f"{d}/{rel}"
319
+
320
+
321
+ def _merge_lits(lst: list[Part]) -> list[Part]:
322
+ out: list[Part] = []
323
+ for p in lst:
324
+ if isinstance(p, Lit) and out and isinstance(out[-1], Lit):
325
+ out[-1] = Lit(out[-1].s + p.s)
326
+ else:
327
+ out.append(p)
328
+ return out
329
+
330
+
331
+ # ---------------------------------------------------------------- expander
332
+ class _ScreenExpander:
333
+ def __init__(self, source: str, data: dict, resolve, dark: bool):
334
+ self.source = source
335
+ self.data = data
336
+ self.resolve = resolve
337
+ self.dark = dark
338
+ self.lines = source.split("\n")
339
+ self.rules: list[Rule] = []
340
+ self.variables: dict[str, str] = {}
341
+ self.rule_order = 0
342
+ self.out: list[str] = []
343
+ self.out_lines: list[int] = []
344
+ self.warnings: list[LayoutWarning] = []
345
+ self.current_component: str | None = None
346
+ self.current_include_line = 0
347
+ self.components: dict[str, Component] = {}
348
+ self.scope_counter = 0
349
+ self.marker_hits = 0
350
+ self.auto_keyed: set[int] = set()
351
+ self.scope_log: list[tuple[str, object]] = []
352
+
353
+ # -- errors
354
+ def err(self, message: str, line: int, word: str | None = None, suggestion: str | None = None):
355
+ c = self.current_component
356
+ if c is None:
357
+ return ExpandError(message, line, self.source, word, suggestion)
358
+ return ExpandError(f"in '{c}': {message}", self.current_include_line, self.source, word, suggestion)
359
+
360
+ def eff_line(self, line: int) -> int:
361
+ return self.current_include_line if self.current_component is not None else line
362
+
363
+ def style_error(self, message, line, word, file, link_line):
364
+ if file is None:
365
+ return self.err(f"style: {message}", line, word)
366
+ return self.err(f"style: in '{file}' line {line}: {message}", link_line, word)
367
+
368
+ def main_fail(self, message, line, word):
369
+ raise self.err(message, line, word)
370
+
371
+ def component_fail(self, name: str, include_line: int):
372
+ def fail(message, _line, word):
373
+ raise ExpandError(f"in '{name}': {message}", include_line, self.source, word)
374
+ return fail
375
+
376
+ def include_error(self, file_name: str, message: str, line: int):
377
+ if not file_name:
378
+ return ExpandError(message, line, self.source)
379
+ return ExpandError(f"in '{file_name}': {message}", line, self.source)
380
+
381
+ # -- .nl file
382
+ @staticmethod
383
+ def _link_href(tag: str, line: int, fail) -> str:
384
+ attrs: dict[str, str] = {}
385
+ inner = tag.removeprefix("<link").removesuffix(">").removesuffix("/")
386
+ for a in ATTR_RE.finditer(inner):
387
+ value = _first(a, 2, 3, 4)
388
+ if value is None:
389
+ continue
390
+ attrs[a.group(1).lower()] = value
391
+ href = attrs.get("href")
392
+ rel = attrs.get("rel")
393
+ if href is None or href.strip() == "" or (rel.lower() if rel is not None else None) != "stylesheet":
394
+ fail('markup: <link> needs rel="stylesheet" and a href', line, None)
395
+ if href.startswith("/") or ".." in href or "://" in href:
396
+ fail(f"style: the file '{href}' is not allowed", line, href)
397
+ if not href.endswith(".css"):
398
+ fail(f"style: the file '{href}' must be a .css file", line, href)
399
+ return href
400
+
401
+ def _scan_file(self, file_lines: list[str], fail) -> FileLayout:
402
+ first = 0
403
+ while first < len(file_lines) and file_lines[first].strip() == "":
404
+ first += 1
405
+ last = len(file_lines) - 1
406
+ while last >= 0 and file_lines[last].strip() == "":
407
+ last -= 1
408
+ if (first >= last or file_lines[first].strip() != "<nativelayout>"
409
+ or file_lines[last].strip() != "</nativelayout>"):
410
+ fail("the file must start with <nativelayout> and end with </nativelayout>",
411
+ first + 1 if first < len(file_lines) else 1, None)
412
+
413
+ style_start = style_end = body_start = body_end = -1
414
+ links: list[tuple[str, int]] = []
415
+ i = first + 1
416
+ in_comment = False
417
+ while i < last:
418
+ t = file_lines[i].strip()
419
+ if in_comment:
420
+ if "-->" in t:
421
+ in_comment = False
422
+ elif t == "":
423
+ pass
424
+ elif t.startswith("<!--"):
425
+ if "-->" not in t:
426
+ in_comment = True
427
+ elif t.startswith("<script"):
428
+ fail("markup: <script> is not allowed", i + 1, None)
429
+ elif t == "<style>":
430
+ if style_start >= 0:
431
+ fail("only one <style> block is allowed", i + 1, None)
432
+ j = i + 1
433
+ while j < last and file_lines[j].strip() != "</style>":
434
+ j += 1
435
+ if j >= last:
436
+ fail("<style> is never closed with </style>", i + 1, None)
437
+ style_start = i + 1
438
+ style_end = j
439
+ i = j
440
+ elif t.startswith("<link"):
441
+ if body_start >= 0:
442
+ fail("a <link> must come before <body>", i + 1, None)
443
+ links.append((self._link_href(t, i + 1, fail), i + 1))
444
+ elif t.startswith("<body"):
445
+ if body_start >= 0:
446
+ fail("the file must have exactly one <body>", i + 1, None)
447
+ j = i + 1
448
+ while j < last and file_lines[j].strip() != "</body>":
449
+ j += 1
450
+ if j >= last:
451
+ fail("<body> is never closed with </body>", i + 1, None)
452
+ body_start = i
453
+ body_end = j
454
+ i = j
455
+ else:
456
+ fail("only <style> and <body> are allowed inside <nativelayout>", i + 1, None)
457
+ i += 1
458
+ if body_start < 0:
459
+ fail("the file must have exactly one <body>", first + 1, None)
460
+ return FileLayout(links, style_start, style_end, body_start, body_end)
461
+
462
+ # -- components: <include>
463
+ def _include_tag_of(self, raw: str, report_line: int, file_name: str) -> IncludeTag | None:
464
+ t = raw.strip()
465
+ if INCLUDE_START.search(t) is None:
466
+ return None
467
+ m = TAG_RE.search(t)
468
+ if m is None or m.start() != 0 or m.end() - 1 != len(t) - 1:
469
+ raise self.include_error(file_name, "markup: <include> must be alone on its line", report_line)
470
+ inner = t[len("<include"):len(t) - 1].removesuffix("/")
471
+ attrs = [(a.group(1), _first(a, 2, 3, 4)) for a in ATTR_RE.finditer(inner)]
472
+ return IncludeTag(attrs)
473
+
474
+ def _component_path(self, frm: str, src: str, line: int) -> str:
475
+ if src.strip() == "" or src.startswith("/") or ".." in src or "://" in src:
476
+ raise self.include_error(frm, f"include: the file '{src}' is not allowed", line)
477
+ if not src.endswith(".nl"):
478
+ raise self.include_error(frm, f"include: the file '{src}' must be a .nl file", line)
479
+ return _join_path(frm, src)
480
+
481
+ def _collect_includes(self, file_name: str, file_lines: list[str], layout: FileLayout,
482
+ chain: list[str], main_line: int) -> None:
483
+ for idx in range(layout.body_start + 1, layout.body_end):
484
+ line = idx + 1 if not file_name else main_line
485
+ tag = self._include_tag_of(file_lines[idx], line, file_name)
486
+ if tag is None:
487
+ continue
488
+ src = tag.get("src")
489
+ if src is None or src.strip() == "":
490
+ raise self.include_error(file_name, "markup: <include> requires a src attribute", line)
491
+ name = self._component_path(file_name, src, line)
492
+ if name == file_name or name in chain:
493
+ raise self.include_error(file_name, f"include: '{name}' includes itself", line)
494
+ if len(chain) >= MAX_INCLUDE_LEVELS:
495
+ raise self.include_error(
496
+ file_name, f"include: too many nested includes (the limit is {MAX_INCLUDE_LEVELS})", line)
497
+ if name in self.components:
498
+ continue
499
+ text = self.resolve(name)
500
+ if text is None:
501
+ raise self.include_error(file_name, f"include: the file '{name}' was not found", line)
502
+ comp_lines = text.split("\n")
503
+ comp_layout = self._scan_file(comp_lines, self.component_fail(name, line))
504
+ self.components[name] = Component(name, text, comp_lines, comp_layout, line)
505
+ self._collect_includes(name, comp_lines, comp_layout, chain + [name], line)
506
+
507
+ def _param_plan(self, name: str, value: str | None) -> ParamPlan:
508
+ if value is None:
509
+ return ParamPlan(name, True, None, None)
510
+ if "{" not in value:
511
+ return ParamPlan(name, value, None, None)
512
+ m = SINGLE_VALUE_RE.fullmatch(value.strip())
513
+ if m is not None:
514
+ return ParamPlan(name, None, m.group(1), None)
515
+ return ParamPlan(name, None, None, self._compile_value(value))
516
+
517
+ def _make_include(self, tag: IncludeTag, file_name: str, line: int) -> IncludeNode:
518
+ src = tag.get("src")
519
+ if src is None:
520
+ raise self.err("markup: <include> requires a src attribute", line)
521
+ component = self.components.get(_join_path(file_name, src))
522
+ if component is None:
523
+ raise self.err(f"include: the file '{src}' was not found", line)
524
+ params: list[ParamPlan] = []
525
+ key = None
526
+ for name, value in tag.attrs:
527
+ n = name.lower()
528
+ if n == "src":
529
+ pass
530
+ elif n == "key":
531
+ key = self._compile_value(value) if value is not None else None
532
+ else:
533
+ params.append(self._param_plan(name, value))
534
+ return IncludeNode(line, component, params, key)
535
+
536
+ # -- input
537
+ def expand(self) -> ExpandedScreen:
538
+ layout = self._scan_file(self.lines, self.main_fail)
539
+ self._collect_includes("", self.lines, layout, [], 0)
540
+ style_start, style_end = layout.style_start, layout.style_end
541
+
542
+ component_loaded: set[str] = set()
543
+ component_sheets: list[tuple[str, int, str]] = []
544
+ for comp in self.components.values():
545
+ for href, _ in comp.layout.links:
546
+ path = _join_path(comp.name, href)
547
+ if path in component_loaded:
548
+ continue
549
+ component_loaded.add(path)
550
+ css = self.resolve(path)
551
+ if css is None:
552
+ raise ExpandError(f"style: the file '{path}' was not found", comp.include_line, self.source, path)
553
+ if "@import" in css:
554
+ raise ExpandError("style: a style file cannot import another file",
555
+ comp.include_line, self.source, path)
556
+ component_sheets.append((path, comp.include_line, css))
557
+
558
+ loaded: set[str] = set()
559
+ sheets: list[tuple[str, int, str]] = []
560
+ for href, link_line in layout.links:
561
+ if href in loaded:
562
+ continue
563
+ loaded.add(href)
564
+ css = self.resolve(href)
565
+ if css is None:
566
+ raise self.err(f"style: the file '{href}' was not found", link_line, href)
567
+ if "@import" in css:
568
+ raise self.err("style: a style file cannot import another file", link_line, href)
569
+ sheets.append((href, link_line, css))
570
+
571
+ key = ["d" if self.dark else "l", "\x01", self.source]
572
+ for comp in self.components.values():
573
+ key += ["\x02", comp.name, "\x03", comp.source]
574
+ for s in component_sheets:
575
+ key += ["\x00", s[2]]
576
+ key.append("\x04")
577
+ for s in sheets:
578
+ key += ["\x00", s[2]]
579
+ cache_key = "".join(key)
580
+
581
+ hit = _cached(cache_key)
582
+ if hit is not None:
583
+ prepared = hit
584
+ self.rules = hit.rules
585
+ else:
586
+ for s in component_sheets:
587
+ self._parse_style(s[2], 1, s[0], s[1])
588
+ for comp in self.components.values():
589
+ l = comp.layout
590
+ if l.style_start >= 0:
591
+ self._parse_style("\n".join(comp.lines[l.style_start:l.style_end]),
592
+ l.style_start + 1, comp.name, comp.include_line)
593
+ for s in sheets:
594
+ self._parse_style(s[2], 1, s[0], s[1])
595
+ if style_start >= 0:
596
+ self._parse_style("\n".join(self.lines[style_start:style_end]), style_start + 1)
597
+ self._resolve_variables()
598
+ for comp in self.components.values():
599
+ self.current_component = comp.name
600
+ self.current_include_line = comp.include_line
601
+ comp.nodes = self._build_nodes(comp.lines, comp.layout.body_start,
602
+ comp.layout.body_end, comp.name)
603
+ self.current_component = None
604
+ prepared = Prepared(self.rules, self._build_nodes(self.lines, layout.body_start,
605
+ layout.body_end, ""))
606
+ _store(cache_key, prepared)
607
+ self._emit(prepared.nodes, Scope(None, self.data))
608
+ return ExpandedScreen("\n".join(self.out), self.out_lines, list(self.warnings), dict(self.scope_log))
609
+
610
+ # -- styles
611
+ def _flatten_media(self, text: str, first_line: int, file, link_line) -> str:
612
+ t = text
613
+ while True:
614
+ at = t.find("@media")
615
+ if at < 0:
616
+ return t
617
+ line = first_line + t.count("\n", 0, at)
618
+ op = t.find("{", at)
619
+ if op < 0:
620
+ raise self.style_error("'@media' without '{'", line, "@media", file, link_line)
621
+ depth = 0
622
+ close = -1
623
+ i = op
624
+ while i < len(t):
625
+ c = t[i]
626
+ if c == "{":
627
+ depth += 1
628
+ elif c == "}":
629
+ depth -= 1
630
+ if depth == 0:
631
+ close = i
632
+ break
633
+ i += 1
634
+ if close < 0:
635
+ raise self.style_error("the '@media' block is never closed", line, "@media", file, link_line)
636
+ condition = WS_RE.sub(" ", t[at + 6:op].strip().lower())
637
+ norm = condition.replace(": ", ":")
638
+ if norm == "(prefers-color-scheme:dark)":
639
+ include = self.dark
640
+ elif norm == "(prefers-color-scheme:light)":
641
+ include = not self.dark
642
+ else:
643
+ raise self.style_error(
644
+ f"the '@media {condition}' is not supported "
645
+ "(use '@media (prefers-color-scheme: dark)' or '... light')",
646
+ line, "@media", file, link_line)
647
+ replacement = t[op + 1:close] if include else "".join(c for c in t[at:close + 1] if c == "\n")
648
+ t = t[:at] + replacement + t[close + 1:]
649
+
650
+ def _parse_style(self, text: str, first_line: int, file: str | None = None, link_line: int = 0) -> None:
651
+ commentless = COMMENT_RE.sub(lambda m: "".join(c for c in m.group(0) if c == "\n"), text)
652
+ clean = self._flatten_media(commentless, first_line, file, link_line)
653
+ for m in RULE_RE.finditer(clean):
654
+ selector_text = m.group(1)
655
+ offset = m.start(1) + (len(selector_text) - len(selector_text.lstrip()))
656
+ line = first_line + clean.count("\n", 0, offset)
657
+ declarations: list[tuple[str, str]] = []
658
+ for part in m.group(2).split(";"):
659
+ idx = part.find(":")
660
+ if idx < 0:
661
+ continue
662
+ name = part[:idx].strip().lower()
663
+ value = part[idx + 1:].replace("!important", "").strip()
664
+ if not name or not value:
665
+ continue
666
+ declarations.append((name, value))
667
+ for raw in selector_text.split(","):
668
+ full = raw.strip()
669
+ selector = full
670
+ state = STATE_NONE
671
+ colon = full.find(":")
672
+ if colon > 0:
673
+ s = full[colon + 1:].lower()
674
+ if s in ("pressed", "active"):
675
+ state = STATE_PRESSED
676
+ elif s == "disabled":
677
+ state = STATE_DISABLED
678
+ elif s == "focus":
679
+ state = STATE_FOCUS
680
+ else:
681
+ raise self.style_error(
682
+ f"the selector '{full}' is not supported (states: :pressed, :disabled, :focus)",
683
+ line, full, file, link_line)
684
+ selector = full[:colon]
685
+ if selector == ":root" and state == STATE_NONE:
686
+ for d in declarations:
687
+ if d[0].startswith("--"):
688
+ self.variables[d[0]] = d[1]
689
+ elif TAG_SELECTOR.fullmatch(selector):
690
+ self.rules.append(Rule(KIND_TAG, selector.lower(), declarations, self.rule_order, line, file, link_line, state))
691
+ self.rule_order += 1
692
+ elif CLASS_SELECTOR.fullmatch(selector):
693
+ self.rules.append(Rule(KIND_CLASS, selector[1:], declarations, self.rule_order, line, file, link_line, state))
694
+ self.rule_order += 1
695
+ elif ID_SELECTOR.fullmatch(selector):
696
+ self.rules.append(Rule(KIND_ID, selector[1:], declarations, self.rule_order, line, file, link_line, state))
697
+ self.rule_order += 1
698
+ else:
699
+ raise self.style_error(f"the selector '{full}' is not supported", line, full, file, link_line)
700
+
701
+ def _substitute_vars(self, value: str, rule: Rule) -> str:
702
+ def rep(m):
703
+ name = m.group(1)
704
+ if name not in self.variables:
705
+ raise self.style_error(f"the variable '{name}' is not defined in ':root'",
706
+ rule.line, name, rule.file, rule.link_line)
707
+ return self.variables[name]
708
+ return VAR_RE.sub(rep, value)
709
+
710
+ def _resolve_variables(self) -> None:
711
+ for rule in self.rules:
712
+ rule.resolved = [(n, self._substitute_vars(v, rule))
713
+ for n, v in rule.declarations if not n.startswith("--")]
714
+
715
+ # -- control lines
716
+ def _parse_control(self, t: str, line: int) -> Control | None:
717
+ if t == "end":
718
+ return Control("end", "")
719
+ if t == "else:":
720
+ return Control("else", "")
721
+ if ELSE_SPACE_RE.fullmatch(t):
722
+ raise self.err("write 'else:' without a space before ':'", line)
723
+ km = KEYWORD_RE.match(t)
724
+ if km is None:
725
+ return None
726
+ keyword = km.group(1)
727
+ if not t.endswith(":"):
728
+ return None
729
+ body = t[:-1]
730
+ if body.endswith(" ") or body.endswith("\t"):
731
+ raise self.err(f"write '{body.rstrip()}:' without a space before ':'", line)
732
+ if keyword == "for":
733
+ m = FOR_RE.fullmatch(t)
734
+ if m is None:
735
+ raise self.err(f"invalid 'for': '{t}'", line, None, "write 'for item in list:'")
736
+ return Control("for", m.group(2), m.group(1))
737
+ condition = body[len(keyword):].strip()
738
+ if not condition:
739
+ raise self.err("invalid condition: ''", line)
740
+ return Control(keyword, condition)
741
+
742
+ def _build_nodes(self, file_lines: list[str], frm: int, to: int, file_name: str) -> list[Node]:
743
+ root: list[Node] = []
744
+ stack: list[Frame] = [Frame(0, root, None)]
745
+ for idx in range(frm, to + 1):
746
+ line = idx + 1
747
+ edge = idx == frm or idx == to
748
+ control = None if edge else self._parse_control(file_lines[idx].strip(), line)
749
+ top = stack[-1]
750
+ if control is None:
751
+ include = None if edge else self._include_tag_of(
752
+ file_lines[idx], line if not file_name else self.current_include_line, file_name)
753
+ if include is not None:
754
+ top.nodes.append(self._make_include(include, file_name, line))
755
+ else:
756
+ top.nodes.append(TextNode(file_lines[idx], line))
757
+ continue
758
+ if control.type == "if":
759
+ node = IfNode(line)
760
+ branch = Branch(control.argument, line)
761
+ node.branches.append(branch)
762
+ top.nodes.append(node)
763
+ stack.append(Frame(line, branch.nodes, node))
764
+ elif control.type in ("elif", "else"):
765
+ if_node = top.if_node
766
+ if if_node is None:
767
+ raise self.err(f"'{control.type}' has no matching 'if'", line)
768
+ if if_node.branches[-1].condition is None:
769
+ raise self.err(f"'{control.type}' has no matching 'if'", line)
770
+ branch = Branch(None if control.type == "else" else control.argument, line)
771
+ if_node.branches.append(branch)
772
+ top.nodes = branch.nodes
773
+ elif control.type == "for":
774
+ node = ForNode(line, control.variable, control.argument)
775
+ top.nodes.append(node)
776
+ stack.append(Frame(line, node.nodes, None))
777
+ else:
778
+ if len(stack) == 1:
779
+ raise self.err("'end' has no matching 'if' or 'for'", line)
780
+ stack.pop()
781
+ if len(stack) > 1:
782
+ open_ = stack[-1]
783
+ word = "if" if open_.if_node is not None else "for"
784
+ raise self.err(f"the '{word}' opened on line {open_.open_line} is never closed with 'end'",
785
+ open_.open_line)
786
+ return root
787
+
788
+ # -- emission
789
+ def _emit(self, nodes: list[Node], scope: Scope) -> None:
790
+ for node in nodes:
791
+ if isinstance(node, TextNode):
792
+ if node.parts is None:
793
+ node.parts = self._compile_line(node.text)
794
+ line = self.eff_line(node.line)
795
+ self.out.append(self._render_parts(node.parts, line, scope))
796
+ self.out_lines.append(line)
797
+ elif isinstance(node, IfNode):
798
+ for branch in node.branches:
799
+ cond = branch.condition
800
+ if cond is None or self._evaluate(branch, cond, scope):
801
+ self._emit(branch.nodes, scope)
802
+ break
803
+ elif isinstance(node, ForNode):
804
+ self._emit_for(node, scope)
805
+ elif isinstance(node, IncludeNode):
806
+ self._emit_include(node, scope)
807
+
808
+ def _emit_for(self, node: ForNode, scope: Scope) -> None:
809
+ ln = self.eff_line(node.line)
810
+ lst = self._lookup(node.list_name, ln, scope)
811
+ if not isinstance(lst, list):
812
+ raise self.err(f"'{node.list_name}' is not a list", ln, node.list_name)
813
+ if scope.has(node.variable):
814
+ raise self.err(f"the name '{node.variable}' already exists", ln, node.variable)
815
+ position_warned = False
816
+ for index, item in enumerate(lst):
817
+ # ids and taps inside the item carry the path of the repetition; it is written with a marker
818
+ # because the key of the item is only known after the item has been emitted.
819
+ marker = f"\x01{self.scope_counter}\x02"
820
+ self.scope_counter += 1
821
+ child_path = marker if not scope.path else scope.path + "/" + marker
822
+ first = len(self.out)
823
+ log_first = len(self.scope_log)
824
+ hits = self.marker_hits
825
+ self._emit(node.nodes, Scope(scope, {node.variable: item}, child_path))
826
+ item_key = Scopes.key_of_item(item)
827
+ if self.marker_hits != hits:
828
+ fl = self._first_level_key(first)
829
+ segment = node.variable + ":" + Scopes.token(fl if fl is not None else item_key, index)
830
+ for k in range(first, len(self.out)):
831
+ if "\x01" in self.out[k]:
832
+ self.out[k] = self.out[k].replace(marker, segment)
833
+ for k in range(log_first, len(self.scope_log)):
834
+ p, it = self.scope_log[k]
835
+ if "\x01" in p:
836
+ self.scope_log[k] = (p.replace(marker, segment), it)
837
+ self.scope_log.append((segment if not scope.path else scope.path + "/" + segment, item))
838
+ keyed = self._apply_key(first, item_key)
839
+ if not keyed and not position_warned and self._holds_fields_or_ids(first):
840
+ position_warned = True
841
+ w = LayoutWarning("the items of this list have no id: using the position", ln)
842
+ if not any(x.message == w.message and x.line == w.line for x in self.warnings):
843
+ self.warnings.append(w)
844
+
845
+ def _emit_include(self, node: IncludeNode, scope: Scope) -> None:
846
+ ln = self.eff_line(node.line)
847
+ given: dict[str, object] = {}
848
+ for p in node.params:
849
+ given[p.name] = self._param_value(p, ln, scope)
850
+ key_text = None
851
+ if node.key is not None:
852
+ kt = self._raw_text(node.key, ln, scope).strip()
853
+ key_text = kt or None
854
+ first = len(self.out)
855
+ saved_component = self.current_component
856
+ saved_line = self.current_include_line
857
+ if saved_component is None:
858
+ self.current_include_line = node.line
859
+ self.current_component = node.component.name
860
+ try:
861
+ self._emit(node.component.nodes, Scope(None, given, scope.path))
862
+ finally:
863
+ self.current_component = saved_component
864
+ self.current_include_line = saved_line
865
+ if key_text is not None:
866
+ self._apply_key(first, key_text)
867
+
868
+ def _param_value(self, p: ParamPlan, line: int, scope: Scope):
869
+ if p.single is not None:
870
+ return self._lookup_path(p.single, p.single.split("."), line, scope)
871
+ if p.parts is not None:
872
+ return self._raw_text(p.parts, line, scope)
873
+ return p.constant
874
+
875
+ def _raw_text(self, parts: list[Part], line: int, scope: Scope) -> str:
876
+ sb = []
877
+ for p in parts:
878
+ if isinstance(p, Lit):
879
+ sb.append(p.s)
880
+ elif isinstance(p, Val):
881
+ sb.append(self._raw_value(p, line, scope))
882
+ return "".join(sb)
883
+
884
+ def _value_text(self, v: Val, line: int, scope: Scope, escape: bool) -> str:
885
+ if not v.ok:
886
+ raise self.err(f"arithmetic is not supported in '{{{v.inner}}}'; give the program's result as a name", line)
887
+ name = v.name
888
+ x = self._lookup_path(name, v.path, line, scope)
889
+ if x is None:
890
+ return ""
891
+ if isinstance(x, str):
892
+ return _escape_html(x) if escape else x
893
+ if isinstance(x, bool):
894
+ return "true" if x else "false"
895
+ if _is_num(x):
896
+ return _format_number(x)
897
+ if isinstance(x, list):
898
+ raise self.err(f"'{{{name}}}' is a list; use 'for {_singular(name)} in {name}:'", line, name)
899
+ if isinstance(x, dict):
900
+ raise self.err(f"'{{{name}}}' is a map; use a name inside it, like '{{{name}.field}}'", line, name)
901
+ return _escape_html(str(x)) if escape else str(x)
902
+
903
+ def _raw_value(self, v: Val, line: int, scope: Scope) -> str:
904
+ return self._value_text(v, line, scope, False)
905
+
906
+ def _val_text(self, v: Val, line: int, scope: Scope) -> str:
907
+ return self._value_text(v, line, scope, True)
908
+
909
+ # -- keys of the repetitions
910
+ def _first_level_key(self, first: int) -> str | None:
911
+ depth = 0
912
+ for k in range(first, len(self.out)):
913
+ for m in TAG_RE.finditer(self.out[k]):
914
+ tag = m.group(0)
915
+ if tag.startswith("<!"):
916
+ continue
917
+ if tag.startswith("</"):
918
+ depth -= 1
919
+ continue
920
+ nm = TAG_NAME_RE.search(tag)
921
+ if nm is None:
922
+ continue
923
+ name = nm.group(1).lower()
924
+ # a key that an inner `for` wrote by itself is not the key of the author
925
+ if depth == 0 and k not in self.auto_keyed:
926
+ km = KEY_ATTR.search(tag)
927
+ if km is not None:
928
+ return km.group(1)
929
+ if name not in VOID_NAMES and not tag.endswith("/>"):
930
+ depth += 1
931
+ return None
932
+
933
+ def _apply_key(self, first: int, key: str | None) -> bool:
934
+ if key is None:
935
+ return False
936
+ escaped = _escape_html(key)
937
+ depth = 0
938
+ for k in range(first, len(self.out)):
939
+ line = self.out[k]
940
+ rebuilt: list[str] | None = None
941
+ copied = 0
942
+ for m in TAG_RE.finditer(line):
943
+ tag = m.group(0)
944
+ if tag.startswith("<!"):
945
+ continue
946
+ if tag.startswith("</"):
947
+ depth -= 1
948
+ continue
949
+ nm = TAG_NAME_RE.search(tag)
950
+ if nm is None:
951
+ continue
952
+ name = nm.group(1).lower()
953
+ if depth == 0 and HAS_KEY.search(tag) is None:
954
+ if rebuilt is None:
955
+ rebuilt = []
956
+ rebuilt.append(line[copied:m.start()])
957
+ n = len(name) + 1
958
+ rebuilt.append(tag[:n] + ' key="' + escaped + '"' + tag[n:])
959
+ copied = m.end()
960
+ if name not in VOID_NAMES and not tag.endswith("/>"):
961
+ depth += 1
962
+ if rebuilt is not None:
963
+ rebuilt.append(line[copied:])
964
+ self.out[k] = "".join(rebuilt)
965
+ self.auto_keyed.add(k)
966
+ return True
967
+
968
+ def _holds_fields_or_ids(self, first: int) -> bool:
969
+ return any(FIELD_OR_ID.search(self.out[k]) for k in range(first, len(self.out)))
970
+
971
+ # -- names
972
+ def _lookup(self, name: str, line: int, scope: Scope):
973
+ return self._lookup_path(name, name.split("."), line, scope)
974
+
975
+ def _missing(self, name: str, line: int, word: str):
976
+ c = self.current_component
977
+ if c is None:
978
+ return self.err(f"the name '{name}' was not given by the program", line, word)
979
+ return ExpandError(f"include: the name '{name}' was not given to '{c}'",
980
+ self.current_include_line, self.source, word)
981
+
982
+ def _lookup_path(self, name: str, parts: list[str], line: int, scope: Scope):
983
+ value = scope.find(parts[0])
984
+ if value is _MISSING:
985
+ raise self._missing(name, line, parts[0])
986
+ for part in parts[1:]:
987
+ cur = value
988
+ if isinstance(cur, dict) and part in cur:
989
+ value = cur[part]
990
+ elif part == "size" and isinstance(cur, (dict, list, str)):
991
+ value = len(cur)
992
+ else:
993
+ raise self._missing(name, line, parts[0])
994
+ return value
995
+
996
+ # -- conditions
997
+ def _tokenize(self, text: str, line: int) -> list[str]:
998
+ tokens: list[str] = []
999
+ pos = 0
1000
+ while pos < len(text):
1001
+ if text[pos].isspace():
1002
+ pos += 1
1003
+ continue
1004
+ m = TOKEN_RE.match(text, pos)
1005
+ if m is None:
1006
+ raise self.err(f"invalid condition: '{text}'", line)
1007
+ tokens.append(m.group(0))
1008
+ pos = m.end()
1009
+ return tokens
1010
+
1011
+ def _evaluate(self, branch: Branch, condition: str, scope: Scope) -> bool:
1012
+ if branch.tokens is None:
1013
+ branch.tokens = self._tokenize(condition, branch.line)
1014
+ return _ConditionParser(self, branch.tokens, condition, branch.line, scope).run()
1015
+
1016
+ # -- compiling lines
1017
+ def _compile_text(self, text: str, lst: list[Part]) -> None:
1018
+ pos = 0
1019
+ for m in BRACE_RE.finditer(text):
1020
+ if m.start() > pos:
1021
+ lst.append(Lit(text[pos:m.start()]))
1022
+ v = m.group(0)
1023
+ if v == "\\{":
1024
+ lst.append(Lit("{"))
1025
+ elif v == "\\}":
1026
+ lst.append(Lit("}"))
1027
+ else:
1028
+ lst.append(Val(m.group(1)))
1029
+ pos = m.end()
1030
+ if pos < len(text):
1031
+ lst.append(Lit(text[pos:]))
1032
+
1033
+ def _compile_value(self, text: str) -> list[Part]:
1034
+ lst: list[Part] = []
1035
+ self._compile_text(text, lst)
1036
+ return _merge_lits(lst)
1037
+
1038
+ def _compile_line(self, text: str) -> list[Part]:
1039
+ lst: list[Part] = []
1040
+ pos = 0
1041
+ for m in TAG_RE.finditer(text):
1042
+ self._compile_text(text[pos:m.start()], lst)
1043
+ lst.append(self._compile_tag(m.group(0)))
1044
+ pos = m.end()
1045
+ self._compile_text(text[pos:], lst)
1046
+ return _merge_lits(lst)
1047
+
1048
+ def _render_parts(self, parts: list[Part], line: int, scope: Scope) -> str:
1049
+ if len(parts) == 1 and isinstance(parts[0], Lit):
1050
+ return parts[0].s
1051
+ sb = []
1052
+ for p in parts:
1053
+ if isinstance(p, Lit):
1054
+ sb.append(p.s)
1055
+ elif isinstance(p, Val):
1056
+ sb.append(self._val_text(p, line, scope))
1057
+ elif isinstance(p, DynTag):
1058
+ sb.append(p.pre if p.pre is not None and not scope.path else self._render_dyn(p, line, scope))
1059
+ return "".join(sb)
1060
+
1061
+ def _style_for(self, tag: str, classes: list[str], id_: str | None, state: int = STATE_NONE) -> str:
1062
+ def match(r: Rule) -> bool:
1063
+ if r.state != state:
1064
+ return False
1065
+ if r.kind == KIND_TAG:
1066
+ return r.name == tag
1067
+ if r.kind == KIND_CLASS:
1068
+ return r.name in classes
1069
+ return id_ is not None and r.name == id_
1070
+ matched = sorted((r for r in self.rules if match(r)), key=lambda r: (r.kind, r.order))
1071
+ return "; ".join(f"{n}: {v}" for r in matched for n, v in r.resolved)
1072
+
1073
+ def _compile_tag(self, tag: str) -> Part:
1074
+ if tag.startswith("<!"):
1075
+ return Lit(tag)
1076
+ if tag.startswith("</"):
1077
+ closing = tag[2:len(tag) - 1].strip().lower()
1078
+ return Lit("</div>" if closing == "body" else tag)
1079
+ nm = TAG_NAME_RE.search(tag)
1080
+ if nm is None:
1081
+ return Lit(tag)
1082
+ name = nm.group(1)
1083
+ lower = name.lower()
1084
+ self_close = tag.endswith("/>")
1085
+ inner = tag[nm.end():len(tag) - (2 if self_close else 1)]
1086
+
1087
+ attrs: list[AttrPlan] = []
1088
+ classes: list[str] = []
1089
+ id_: str | None = None
1090
+ any_brace = False
1091
+ for a in ATTR_RE.finditer(inner):
1092
+ key = a.group(1).lower()
1093
+ value = _first(a, 2, 3, 4)
1094
+ brace = value is not None and "{" in value
1095
+ single = None
1096
+ text_parts = None
1097
+ if brace:
1098
+ any_brace = True
1099
+ sm = SINGLE_VALUE_RE.fullmatch(value.strip())
1100
+ single = sm.group(1) if sm is not None else None
1101
+ text_parts = self._compile_value(value)
1102
+ else:
1103
+ if key == "class":
1104
+ classes = [c for c in (value or "").split(" ") if c]
1105
+ if key == "id":
1106
+ id_ = value
1107
+ attrs.append(AttrPlan(a.group(1), key, value, brace, single, text_parts))
1108
+ plan = DynTag(name, lower, self_close, self._style_for(lower, classes, id_), attrs,
1109
+ (self._style_for(lower, classes, id_, STATE_PRESSED),
1110
+ self._style_for(lower, classes, id_, STATE_DISABLED),
1111
+ self._style_for(lower, classes, id_, STATE_FOCUS)))
1112
+ if any_brace:
1113
+ return plan
1114
+ pre = self._render_dyn(plan, 0, Scope(None, {}))
1115
+ # an id or a class may need the path of a repetition, only known when the line is emitted
1116
+ if any(a.key in ("id", "class") for a in attrs):
1117
+ plan.pre = pre
1118
+ return plan
1119
+ return Lit(pre)
1120
+
1121
+ def _render_dyn(self, t: DynTag, line: int, scope: Scope) -> str:
1122
+ result: list[Attr] = []
1123
+ inline = ""
1124
+ scoped = False
1125
+ for a in t.attrs:
1126
+ value = a.value
1127
+ if a.brace:
1128
+ if a.key in ("id", "class"):
1129
+ raise self.err(f"markup: '{a.key}' cannot be generated by value", line, a.key)
1130
+ if a.key in ("checked", "disabled", "readonly") and a.single is not None:
1131
+ flag = self._lookup(a.single, line, scope)
1132
+ if flag is True:
1133
+ result.append(Attr(a.orig, "true"))
1134
+ continue
1135
+ value = self._render_parts(a.text_parts, line, scope)
1136
+ if a.key in ("id", "class") and scope.path:
1137
+ scoped = True
1138
+ if a.key == "id" and value is not None:
1139
+ value = value + Scopes.SEP + scope.path
1140
+ if a.key == "style":
1141
+ inline = value or ""
1142
+ else:
1143
+ result.append(Attr(a.orig, value))
1144
+ if scoped:
1145
+ result.append(Attr("scope", scope.path))
1146
+ self.marker_hits += 1
1147
+
1148
+ parts: list[str] = []
1149
+ if t.rule_style:
1150
+ parts.append(t.rule_style)
1151
+ inline_style = inline.strip().rstrip(";").strip()
1152
+ if inline_style:
1153
+ parts.append(inline_style)
1154
+ if parts:
1155
+ result.append(Attr("style", "; ".join(parts)))
1156
+ for k in range(3):
1157
+ if t.state_styles[k]:
1158
+ result.append(Attr(STATE_ATTRIBUTES[k], t.state_styles[k]))
1159
+
1160
+ sb = ["<", "div" if t.lower == "body" else t.name]
1161
+ for a in result:
1162
+ sb.append(" " + a.key)
1163
+ if a.value is not None:
1164
+ sb.append('="' + a.value.replace('"', "&quot;") + '"')
1165
+ sb.append(" />" if t.self_close else ">")
1166
+ return "".join(sb)
1167
+
1168
+
1169
+ class _ConditionParser:
1170
+ def __init__(self, owner: _ScreenExpander, tokens: list[str], text: str, line: int, scope: Scope):
1171
+ self.o = owner
1172
+ self.tokens = tokens
1173
+ self.text = text
1174
+ self.line = line
1175
+ self.scope = scope
1176
+ self.pos = 0
1177
+
1178
+ def run(self) -> bool:
1179
+ value = self.parse_or()
1180
+ if self.pos != len(self.tokens):
1181
+ raise self.bad()
1182
+ return value
1183
+
1184
+ def bad(self):
1185
+ return self.o.err(f"invalid condition: '{self.text}'", self.line)
1186
+
1187
+ def peek(self) -> str | None:
1188
+ return self.tokens[self.pos] if self.pos < len(self.tokens) else None
1189
+
1190
+ def parse_or(self) -> bool:
1191
+ v = self.parse_and()
1192
+ while self.peek() == "or":
1193
+ self.pos += 1
1194
+ r = self.parse_and()
1195
+ v = v or r
1196
+ return v
1197
+
1198
+ def parse_and(self) -> bool:
1199
+ v = self.parse_not()
1200
+ while self.peek() == "and":
1201
+ self.pos += 1
1202
+ r = self.parse_not()
1203
+ v = v and r
1204
+ return v
1205
+
1206
+ def parse_not(self) -> bool:
1207
+ if self.peek() == "not":
1208
+ self.pos += 1
1209
+ return not self.parse_not()
1210
+ if self.peek() == "(":
1211
+ self.pos += 1
1212
+ v = self.parse_or()
1213
+ if self.peek() != ")":
1214
+ raise self.bad()
1215
+ self.pos += 1
1216
+ return v
1217
+ return self.parse_compare()
1218
+
1219
+ def parse_compare(self) -> bool:
1220
+ left = self.operand()
1221
+ op = self.peek()
1222
+ if op is not None and op in COMPARATORS:
1223
+ self.pos += 1
1224
+ right = self.operand()
1225
+ return self.compare(op, left, right)
1226
+ if isinstance(left, bool):
1227
+ return left
1228
+ raise self.o.err(f"invalid condition: '{self.text}'", self.line, None,
1229
+ "a name alone must be true or false")
1230
+
1231
+ def operand(self):
1232
+ token = self.peek()
1233
+ if token is None:
1234
+ raise self.bad()
1235
+ self.pos += 1
1236
+ if token == "true":
1237
+ return True
1238
+ if token == "false":
1239
+ return False
1240
+ if token.startswith('"'):
1241
+ return token[1:len(token) - 1]
1242
+ if token[0].isdigit() or token[0] == "-":
1243
+ return float(token)
1244
+ if token in ("and", "or", "not", "(", ")") or token in COMPARATORS:
1245
+ raise self.bad()
1246
+ return self.o._lookup(token, self.line, self.scope)
1247
+
1248
+ def compare(self, op: str, a, b) -> bool:
1249
+ x = float(a) if _is_num(a) else None
1250
+ y = float(b) if _is_num(b) else None
1251
+ if x is not None and y is not None:
1252
+ return {"==": x == y, "!=": x != y, "<": x < y, ">": x > y, "<=": x <= y}.get(op, x >= y)
1253
+ if op == "==":
1254
+ return _equals(a, b)
1255
+ if op == "!=":
1256
+ return not _equals(a, b)
1257
+ if isinstance(a, str) and isinstance(b, str):
1258
+ if op == "<":
1259
+ return a < b
1260
+ if op == ">":
1261
+ return a > b
1262
+ if op == "<=":
1263
+ return a <= b
1264
+ return a >= b
1265
+ raise self.o.err(f"invalid condition: '{self.text}'", self.line, None,
1266
+ f"these values cannot be compared with '{op}'")