"""µFrame parser — .uf source text → IR tree. Line-oriented, indentation-based (2-space). Each line is parsed as: (indent_level, keyword, arguments) Nesting is determined by indentation: children are indented deeper than their parent. """ from __future__ import annotations import re import shlex from typing import Sequence from uframe.errors import ParseError from uframe.ir import ( IRNode, Page, Box, Row, Col, Spacer, Pad, Heading, Text, Label, Divider, Link, ListNode, ListItem, Gauge, Sparkline, Status, Table, TextSpan, Form, Field, Password, Radio, Checkbox, FormButton, Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl, SourceType, BorderWeight, HeadingLevel, DividerStyle, ListStyle, Align, Style, ) # --------------------------------------------------------------------------- # Tokenisation helpers # --------------------------------------------------------------------------- _INDENT_RE = re.compile(r"^( *)") _MODIFIER_RE = re.compile(r"@(\w+)\{([^}]*)\}") def _indent_level(line: str) -> int: """Count leading spaces and return indent level (2 spaces = 1 level).""" spaces = len(_INDENT_RE.match(line).group(1)) # type: ignore[union-attr] return spaces // 2 def _split_args(text: str) -> list[str]: """Split argument string respecting quoted tokens.""" try: return shlex.split(text) except ValueError: return text.split() def parse_inline(content: str) -> list[TextSpan]: """Parse @modifier{text} syntax into a list of TextSpan objects. Supported modifiers: @bold{}, @italic{}, @under{}, @color{hex}{}, @bg{hex}{}. """ spans: list[TextSpan] = [] pos = 0 # Match @modifier{content} — including nested @color{hex}{text} pattern = re.compile( r"@(bold|italic|under|color|bg)" r"(?:\{([0-9a-fA-F]{3})\})?" # optional hex arg for color/bg r"\{([^}]*)\}" ) for m in pattern.finditer(content): # Add plain text before this modifier if m.start() > pos: spans.append(TextSpan(text=content[pos:m.start()])) mod = m.group(1) hex_arg = m.group(2) inner = m.group(3) span = TextSpan(text=inner) if mod == "bold": span.bold = True elif mod == "italic": span.italic = True elif mod == "under": span.underline = True elif mod == "color" and hex_arg: span.fg = hex_arg elif mod == "bg" and hex_arg: span.bg = hex_arg spans.append(span) pos = m.end() # Trailing plain text if pos < len(content): spans.append(TextSpan(text=content[pos:])) # If no modifiers found, return single plain span if not spans: spans.append(TextSpan(text=content)) return spans # --------------------------------------------------------------------------- # Line-level parsing — keyword dispatch # --------------------------------------------------------------------------- def _parse_border_weight(s: str) -> BorderWeight: return { "light": BorderWeight.LIGHT, "heavy": BorderWeight.HEAVY, "double": BorderWeight.DOUBLE, "rounded": BorderWeight.ROUNDED, }.get(s.lower(), BorderWeight.LIGHT) def _parse_divider_style(s: str) -> DividerStyle: return { "light": DividerStyle.LIGHT, "heavy": DividerStyle.HEAVY, "double": DividerStyle.DOUBLE, "dash": DividerStyle.DASH, "dot": DividerStyle.DOT, }.get(s.lower(), DividerStyle.LIGHT) def _parse_heading_level(s: str) -> HeadingLevel: return { "1": HeadingLevel.H1, "2": HeadingLevel.H2, "3": HeadingLevel.H3, }.get(s, HeadingLevel.H1) def _parse_list_style(s: str) -> ListStyle: return { "bullet": ListStyle.BULLET, "dash": ListStyle.DASH, "number": ListStyle.NUMBER, "arrow": ListStyle.ARROW, }.get(s.lower(), ListStyle.BULLET) def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode: """Parse a single line into an IR node based on the keyword.""" if keyword == "page": title = args[0] if args else "Untitled" width = int(args[1]) if len(args) > 1 else 64 return Page(title=title, width=width, source_line=line_num) elif keyword == "box": # box [weight] "title" if len(args) >= 2: weight = _parse_border_weight(args[0]) title = args[1] elif len(args) == 1: # Could be weight or title if args[0].lower() in ("light", "heavy", "double", "rounded"): weight = _parse_border_weight(args[0]) title = "" else: weight = BorderWeight.LIGHT title = args[0] else: weight = BorderWeight.LIGHT title = "" return Box(title=title, weight=weight, source_line=line_num) elif keyword == "row": # Table row (has | separators) vs layout Row (has gap number or nothing) raw = " ".join(args) if "|" in raw: # Split by | and strip quotes from each cell, preserving @modifiers cells: list[str] = [] for part in raw.split("|"): cell = part.strip().strip('"') cells.append(cell) return _TableRow(cells, line_num) gap = int(args[0]) if args else 1 return Row(gap=gap, source_line=line_num) elif keyword == "col": w = int(args[0]) if args else None return Col(col_width=w, source_line=line_num) elif keyword == "spacer": lines = int(args[0]) if args else 1 return Spacer(lines=lines, source_line=line_num) elif keyword == "pad": vals = [int(a) for a in args[:4]] while len(vals) < 4: vals.append(0) return Pad(top=vals[0], right=vals[1], bottom=vals[2], left=vals[3], source_line=line_num) elif keyword == "heading": level_str = args[0] if args else "1" text = args[1] if len(args) > 1 else "" return Heading(level=_parse_heading_level(level_str), text=text, source_line=line_num) elif keyword == "text": content = args[0] if args else "" spans = parse_inline(content) return Text(content=content, spans=spans, source_line=line_num) elif keyword == "label": key = args[0] if args else "" value = args[1] if len(args) > 1 else "" return Label(key=key, value=value, source_line=line_num) elif keyword == "divider": style = _parse_divider_style(args[0]) if args else DividerStyle.LIGHT return Divider(divider_style=style, source_line=line_num) elif keyword == "link": display = args[0] if args else "" dest = args[1] if len(args) > 1 else "" return Link(display=display, dest=dest, source_line=line_num) elif keyword == "list": style = _parse_list_style(args[0]) if args else ListStyle.BULLET return ListNode(list_style=style, source_line=line_num) elif keyword == "item": content = args[0] if args else "" return ListItem(content=content, source_line=line_num) # Style modifiers (applied to parent) elif keyword == "align": val = args[0].lower() if args else "left" align = {"left": Align.LEFT, "center": Align.CENTER, "right": Align.RIGHT}.get(val, Align.LEFT) return _StyleDirective("align", align, line_num) elif keyword == "color": return _StyleDirective("fg", args[0] if args else None, line_num) elif keyword == "bg": return _StyleDirective("bg", args[0] if args else None, line_num) elif keyword == "bold": return _StyleDirective("bold", True, line_num) elif keyword == "italic": return _StyleDirective("italic", True, line_num) elif keyword == "underline": return _StyleDirective("underline", True, line_num) # Phase 4 placeholders elif keyword == "gauge": label = args[0] if args else "" val_str = args[1] if len(args) > 1 else "0" try: value = float(val_str) except ValueError: value = 0 # $variable — resolved at runtime try: max_val = float(args[2]) if len(args) > 2 else 100 except ValueError: max_val = 100 try: bar_width = int(args[3]) if len(args) > 3 else 28 except ValueError: bar_width = 28 # Parse warn=N crit=N from remaining args warn = crit = None for a in args[4:]: if a.startswith("warn="): warn = float(a[5:]) elif a.startswith("crit="): crit = float(a[5:]) return Gauge(label=label, value=value, max_val=max_val, bar_width=bar_width, warn=warn, crit=crit, source_line=line_num) elif keyword == "sparkline": label = args[0] if args else "" vals_str = args[1] if len(args) > 1 else "" values = [float(v) for v in vals_str.split(",") if v.strip()] if vals_str else [] width = int(args[2]) if len(args) > 2 else 20 return Sparkline(label=label, values=values, spark_width=width, source_line=line_num) elif keyword == "status": label = args[0] if args else "" state = args[1] if len(args) > 1 else "unknown" return Status(label=label, state=state, source_line=line_num) elif keyword == "table": title = args[0] if args else "" return Table(title=title, source_line=line_num) elif keyword == "columns": # columns "Name" 24 | "Hops" 6 | "Status" 10 # Re-join args and split by | raw = " ".join(args) cols: list[tuple[str, int]] = [] for part in raw.split("|"): tokens = _split_args(part.strip()) if tokens: col_name = tokens[0] col_w = int(tokens[1]) if len(tokens) > 1 else 0 cols.append((col_name, col_w)) return _TableColumns(cols, line_num) # Forms elif keyword == "form": form_name = args[0] if args else "" return Form(form_name=form_name, source_line=line_num) elif keyword == "field": name = args[0] if args else "" width = int(args[1]) if len(args) > 1 else 24 placeholder = args[2] if len(args) > 2 else "" return Field(field_name=name, field_width=width, placeholder=placeholder, source_line=line_num) elif keyword == "password": name = args[0] if args else "" width = int(args[1]) if len(args) > 1 else 24 placeholder = args[2] if len(args) > 2 else "" return Password(field_name=name, field_width=width, placeholder=placeholder, source_line=line_num) elif keyword == "radio": group = args[0] if args else "" raw = " ".join(args[1:]) if len(args) > 1 else "" options = [o.strip().strip('"') for o in raw.split("|")] if raw else [] return Radio(group=group, options=options, source_line=line_num) elif keyword == "checkbox": name = args[0] if args else "" label_text = args[1] if len(args) > 1 else "" return Checkbox(field_name=name, checkbox_label=label_text, source_line=line_num) elif keyword == "button": label_text = args[0] if args else "" dest = args[1] if len(args) > 1 else "" return FormButton(button_label=label_text, dest=dest, source_line=line_num) # Dynamic features elif keyword == "let": # let name = "value" or let name = 1,2,3 raw = " ".join(args) eq = raw.find("=") if eq != -1: var_name = raw[:eq].strip() var_value = raw[eq + 1:].strip().strip('"') else: var_name = args[0] if args else "" var_value = args[1] if len(args) > 1 else "" return Let(var_name=var_name, var_value=var_value, source_line=line_num) elif keyword == "source": # source cpu : shell "grep 'cpu' /proc/stat" # source name : type "command" raw = " ".join(args) colon = raw.find(":") if colon != -1: var_name = raw[:colon].strip() rest = raw[colon + 1:].strip() parts = _split_args(rest) src_type_str = parts[0] if parts else "shell" command = parts[1] if len(parts) > 1 else "" src_type = { "shell": SourceType.SHELL, "file": SourceType.FILE, "json": SourceType.JSON, "python": SourceType.PYTHON, "rns": SourceType.RNS, "param": SourceType.PARAM, }.get(src_type_str.lower(), SourceType.SHELL) # Parse optional timeout timeout = 5 for p in parts[2:]: if p.startswith("timeout"): try: timeout = int(p.split("=")[1]) if "=" in p else int(parts[parts.index(p) + 1]) except (ValueError, IndexError): pass return Source(var_name=var_name, source_type=src_type, command=command, timeout=timeout, source_line=line_num) else: return Source(var_name=args[0] if args else "", source_line=line_num) elif keyword == "if": condition = " ".join(args) return IfBlock(condition=condition, source_line=line_num) elif keyword == "elif": condition = " ".join(args) return _ElifBranch(condition, line_num) elif keyword == "else": return _ElseBranch(line_num) elif keyword == "for": # for item in $collection var_name = args[0] if args else "item" # Skip "in" keyword iterable = args[2] if len(args) > 2 else (args[1] if len(args) > 1 else "") return ForLoop(var_name=var_name, iterable=iterable, source_line=line_num) elif keyword == "cache": seconds = int(args[0]) if args else 0 return CacheControl(seconds=seconds, source_line=line_num) elif keyword == "on_submit": form_name = args[0] if args else "" return OnSubmit(form_name=form_name, source_line=line_num) elif keyword == "state": state_name = args[0] if args else "" path = args[1] if len(args) > 1 else "" return StateDecl(state_name=state_name, path=path, source_line=line_num) elif keyword in ("set", "append", "prepend"): # State operations — store as text nodes with metadata for the compiler content = " ".join([keyword] + args) return Text(content=content, source_line=line_num) else: raise ParseError(f"Unknown keyword: {keyword!r}", line=line_num) class _ElifBranch(IRNode): """Temporary node — absorbed by parent IfBlock during tree building.""" def __init__(self, condition: str, line_num: int): super().__init__(source_line=line_num) self.condition = condition class _ElseBranch(IRNode): """Temporary node — absorbed by parent IfBlock during tree building.""" def __init__(self, line_num: int): super().__init__(source_line=line_num) class _TableColumns(IRNode): """Temporary node — absorbed by parent Table during tree building.""" def __init__(self, columns: list[tuple[str, int]], line_num: int): super().__init__(source_line=line_num) self.columns = columns class _TableRow(IRNode): """Temporary node — absorbed by parent Table during tree building.""" def __init__(self, cells: list[str], line_num: int): super().__init__(source_line=line_num) self.cells = cells # --------------------------------------------------------------------------- # Style directives — pseudo-nodes that modify their parent's style # --------------------------------------------------------------------------- class _StyleDirective(IRNode): """Temporary node representing a style modifier (align, color, bold, etc.). These are absorbed by the parent during tree building and never appear in the final IR tree. """ def __init__(self, attr: str, value: object, line_num: int): super().__init__(source_line=line_num) self.attr = attr self.value = value # --------------------------------------------------------------------------- # Tree builder # --------------------------------------------------------------------------- def parse(source: str) -> Page: """Parse a .uf source string into an IR tree rooted at a Page node. Returns the Page node with all children attached. """ lines = source.split("\n") # Stack: list of (indent_level, node) stack: list[tuple[int, IRNode]] = [] root: Page | None = None for line_num, raw_line in enumerate(lines, start=1): # Skip blank lines and comments stripped = raw_line.strip() if not stripped or stripped.startswith("#"): continue indent = _indent_level(raw_line) # Split into keyword + arguments parts = stripped.split(None, 1) keyword = parts[0].lower() arg_str = parts[1] if len(parts) > 1 else "" args = _split_args(arg_str) # Parse this line into a node node = _parse_line(keyword, args, line_num) # Pop stack back to find the parent (parent indent < this indent) while stack and stack[-1][0] >= indent: stack.pop() if isinstance(node, _StyleDirective): # Apply style directive to the current top of stack (parent) if stack: parent = stack[-1][1] setattr(parent.style, node.attr, node.value) continue # Table children: columns and rows are absorbed by the Table node if isinstance(node, _TableColumns): if stack and isinstance(stack[-1][1], Table): stack[-1][1].columns = node.columns continue if isinstance(node, _TableRow): if stack and isinstance(stack[-1][1], Table): stack[-1][1].rows.append(node.cells) continue # elif/else branches are absorbed by the nearest IfBlock ancestor if isinstance(node, _ElifBranch): # Find the IfBlock in the stack for si in range(len(stack) - 1, -1, -1): if isinstance(stack[si][1], IfBlock): # Collect subsequent children under this elif stack[si][1].elif_branches.append((node.condition, [])) break continue if isinstance(node, _ElseBranch): # Find the IfBlock in the stack — mark it for else collection for si in range(len(stack) - 1, -1, -1): if isinstance(stack[si][1], IfBlock): stack[si][1].else_children = [] # will be filled by subsequent children break continue # Attach to parent if stack: parent = stack[-1][1] parent.children.append(node) elif isinstance(node, Page): root = node else: # Auto-wrap in a default Page if source doesn't start with `page` root = Page(title="Untitled", width=64, source_line=0) root.children.append(node) stack.append((-1, root)) # Push onto stack if isinstance(node, Page) and root is node: stack.append((-1, node)) else: stack.append((indent, node)) if root is None: root = Page(title="Untitled", width=64, source_line=0) return root